Compare commits
219 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f85b92a885 | |||
| 84640a0dc4 | |||
| 4c58603009 | |||
| b7e7073425 | |||
| 94b169f31c | |||
| 2db23cec7a | |||
| b81c4aa600 | |||
| 35fab6cbf7 | |||
| 404698521f | |||
| 9f8b451d15 | |||
| 4f18023284 | |||
| 6c309f1331 | |||
| 03d725ea3e | |||
| 611c940527 | |||
| 3b2a0a119f | |||
| a7de3296b3 | |||
| 88b351a96e | |||
| bc239119f3 | |||
| 30dfbce426 | |||
| 65ba1cc82a | |||
| 4cfea784a9 | |||
| 016a2bd270 | |||
| 39d56cfcf3 | |||
| 3f287d7417 | |||
| 64ab24864a | |||
| 3c1ec4077f | |||
| 541e2ed713 | |||
| ff498ce1a4 | |||
| efb3534f3a | |||
| 4192a64c0f | |||
| f20e54a068 | |||
| de4b1d7c7e | |||
| 3d916d7357 | |||
| df423ab906 | |||
| d023209f13 | |||
| 93a3beb5d6 | |||
| 54710b35e9 | |||
| 7a9a8b7819 | |||
| 057b30b89e | |||
| a3071a53cb | |||
| 4faaa5246b | |||
| 0ed9cbf666 | |||
| 4668c0950b | |||
| b728acd841 | |||
| c5498273c3 | |||
| 590a07bc13 | |||
| c9f2134ad4 | |||
| dda62265c9 | |||
| 297f2252a3 | |||
| dbd9f00061 | |||
| cacfcac86a | |||
| 873e70c7ea | |||
| 0c91ab026d | |||
| ce76a003f7 | |||
| ac188c40a5 | |||
| d9ab538ef4 | |||
| 7602bf2293 | |||
| d352e9264b | |||
| 6752765e2b | |||
| 0fe7141949 | |||
| b20d6dec66 | |||
| e652dece9b | |||
| 96a44886ef | |||
| f422d4158c | |||
| 70ec060b7a | |||
| 3b41a45757 | |||
| 19e1ca505d | |||
| f75c40b417 | |||
| 649c0f124b | |||
| 6026c24450 | |||
| ae5c61a179 | |||
| 7657f48f1e | |||
| 61e62a6904 | |||
| 9a252c8dde | |||
| 8db6b4d230 | |||
| d5e6a8f6da | |||
| 06cd3493fd | |||
| 6a8f0e9143 | |||
| 6f8c2201ed | |||
| ec49e24c8d | |||
| cac62c6caf | |||
| 619f069358 | |||
| ddab0db781 | |||
| 443b11f287 | |||
| 3c9e473823 | |||
| 3ac32dc3bc | |||
| 29ef17f4f3 | |||
| fc82f7a0cb | |||
| b743754ec2 | |||
| c4ffe418b5 | |||
| 6cf880506d | |||
| aca34e2dfb | |||
| c07a0f0f7e | |||
| 776e5edb68 | |||
| 8b0dd732f7 | |||
| f12d29563a | |||
| a4995606e5 | |||
| 0a8a755909 | |||
| 719de12a6c | |||
| 72018aa389 | |||
| 1e73ea04f1 | |||
| 72708475a3 | |||
| e07d8436b7 | |||
| 1711ee6a1f | |||
| 369cf0a7c9 | |||
| 3f2813d16f | |||
| fddac2aa2f | |||
| 1261e93ede | |||
| b6165e56e5 | |||
| 5e281c534a | |||
| 9082281225 | |||
| 058d6089b1 | |||
| ba0cb07c91 | |||
| 4228e9a384 | |||
| 035ec0c0dc | |||
| f0c93ffa3b | |||
| ad4fe3d783 | |||
| dcbe018297 | |||
| 36fb71699b | |||
| 027fbec606 | |||
| 730dbfaf7b | |||
| 267a975455 | |||
| 7bd1548f71 | |||
| 9f3b3450fa | |||
| ba90ad8132 | |||
| 9157740069 | |||
| fd885c1bc8 | |||
| 8205590f8d | |||
| 939b910372 | |||
| 70cea78c2f | |||
| 4e4dbb8783 | |||
| e4e1d1da49 | |||
| af6f81e0a7 | |||
| 734ccc337f | |||
| 87fcaa6a0d | |||
| 782f36ed51 | |||
| 9a851de624 | |||
| 95135a665b | |||
| 46c6a9f174 | |||
| ec3853a78a | |||
| 0becc1439b | |||
| a6fe1c0d7c | |||
| 77339d5c58 | |||
| e95ad90055 | |||
| 1d6905ccc8 | |||
| 403eb49de0 | |||
| 717ac1b5d7 | |||
| 8276e5050a | |||
| 436e339c48 | |||
| c32c75f77e | |||
| ce2d76447c | |||
| 5275be8588 | |||
| 8d07b6c79e | |||
| 02138f5728 | |||
| 61f95fb9ed | |||
| cc74901761 | |||
| 9f6608fc8f | |||
| e63b4634ea | |||
| 593d737e26 | |||
| 06dbc0e27a | |||
| 7e0938fe7d | |||
| 9eba6ac107 | |||
| 66f906a629 | |||
| 5f4759a5e8 | |||
| 25c767ddf2 | |||
| 7e06e58ab6 | |||
| 2fd914916f | |||
| 4168167f24 | |||
| 22a13636e8 | |||
| aebb6baa2c | |||
| 3f3156db07 | |||
| 102c0b74a0 | |||
| 8fb81bc1ed | |||
| c1bc73da8e | |||
| 3b8d40fea3 | |||
| 2f5abc034e | |||
| 7a01733334 | |||
| 7d5611d00b | |||
| e5821ef3f8 | |||
| 43231f44d2 | |||
| ab0b9c3199 | |||
| d12fc5d282 | |||
| 4c3d67994b | |||
| 126bad7d99 | |||
| 28fae75258 | |||
| 058bd76719 | |||
| d56b57ee40 | |||
| 8762552234 | |||
| 6cbf9be052 | |||
| 3687fbeeb9 | |||
| a6543c1dc5 | |||
| 4558dd578a | |||
| 8647f52fbc | |||
| 304affb837 | |||
| 2e3f90384d | |||
| 16b9ed9392 | |||
| f9c6802939 | |||
| f2debd8a5b | |||
| f8f14eea0f | |||
| 64c19932a7 | |||
| 317d8f25d0 | |||
| 87f74b1cd5 | |||
| f7fda0adca | |||
| 0ca39a2e34 | |||
| 16d9af8b96 | |||
| 2ac894b5d1 | |||
| 7e81e50e3e | |||
| d8945536c4 | |||
| f30e90ef8d | |||
| 00f82f8cba | |||
| f953a36b86 | |||
| 4bc7f9eaac | |||
| 670de547ad | |||
| a684d84edf | |||
| 34729cf1cf | |||
| dd16b39218 | |||
| 0bd362f582 | |||
| 0afa2d8771 | |||
| 93af4e6bab |
+78
-36
@@ -1,13 +1,27 @@
|
||||
# CI runs first; build only proceeds if all checks pass.
|
||||
#
|
||||
# Push to dev: typecheck + lint + test → build :dev + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test → build :latest + :<sha> + :<version>
|
||||
# Pull request: no CI run — code is validated on dev push before any PR is opened
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
|
||||
#
|
||||
# main pushes are NOT gated here: a merge to main only happens after
|
||||
# dev has already passed CI, and the release tag is the sole trigger
|
||||
# for a production image. Re-running CI on the merge commit just burns
|
||||
# runner time without changing the outcome.
|
||||
#
|
||||
# To cut a release:
|
||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||
# The tag push triggers this workflow; build job pushes :latest + :<version>.
|
||||
#
|
||||
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
|
||||
# gating on branch push is already enough.
|
||||
#
|
||||
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
||||
# honor `on.push.branches` as a filter — merge commits landing on main
|
||||
# still trigger the workflow, producing redundant runs on the same SHA
|
||||
# that was already gated on dev. Every job therefore repeats the ref
|
||||
# check so main pushes trigger the workflow but every job skips
|
||||
# immediately (no runner time, no duplicate work).
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# REGISTRY_USER — your Forgejo username
|
||||
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
|
||||
@@ -28,25 +42,38 @@ on:
|
||||
- "assets/**"
|
||||
- "fable-mcp/**"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
# pull_request trigger intentionally omitted — all changes go through dev
|
||||
# first, where CI already runs on push. PR runs would be redundant duplication.
|
||||
|
||||
# Cancel older runs on the same branch when a newer push lands. Tag runs
|
||||
# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and
|
||||
# are never cancelled, so a release build can't kill itself mid-flight.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
# Least-privilege default. Jobs that need more (build pushes to the
|
||||
# registry) upgrade explicitly.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
runs-on: py3.12-node22
|
||||
# Skip on main merge-commit pushes — see workflow header comment.
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- name: Cache npm download cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
path: ~/.npm
|
||||
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: npm-cache-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -58,52 +85,57 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
runs-on: py3.12-node22
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install --break-system-packages ruff
|
||||
|
||||
# ruff is pre-installed in the ci-runner base image — no install
|
||||
# step needed, lint runs in ~2s.
|
||||
- name: Lint
|
||||
run: ruff check src/
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
runs-on: py3.12-node22
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Python 3.12 is pre-installed in the runner-base image (py3.12-node22).
|
||||
- name: Cache pip wheels
|
||||
uses: actions/cache@v3
|
||||
- name: Cache uv packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: pip-
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: uv-
|
||||
|
||||
- name: Create virtual environment
|
||||
run: python3.12 -m venv /opt/venv
|
||||
run: uv venv /opt/venv
|
||||
|
||||
- name: Install package with dev deps
|
||||
run: |
|
||||
/opt/venv/bin/pip install --upgrade pip setuptools wheel
|
||||
/opt/venv/bin/pip install --no-build-isolation http-ece
|
||||
/opt/venv/bin/pip install -e ".[dev]"
|
||||
# http-ece doesn't declare setuptools as a build dep, and uv
|
||||
# creates bare venvs without it. Install setuptools first so
|
||||
# --no-build-isolation can find it.
|
||||
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: Run tests
|
||||
run: /opt/venv/bin/python -m pytest tests/ -v
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
needs: [typecheck, lint, test]
|
||||
# Build on dev branch pushes and version tag pushes only.
|
||||
# main branch pushes run CI for safety but do not build —
|
||||
# the release tag (v*) is the sole trigger for a production image.
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
|
||||
runs-on: py3.12-node22
|
||||
# Mirrors the ref guard on the gate jobs above — main merge-commit
|
||||
# pushes skip here too, so no production image is ever built from a
|
||||
# raw main push (only from the v* tag the release creates).
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -122,11 +154,14 @@ jobs:
|
||||
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Free disk space
|
||||
# Self-hosted runner housekeeping. Two-step cleanup:
|
||||
# 1. Prune dangling containers/images globally (stops the runner
|
||||
# from accumulating cruft from past failed builds).
|
||||
# 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip
|
||||
# mount cache survives but old intermediate layers don't
|
||||
# accumulate indefinitely.
|
||||
run: |
|
||||
# Remove all unused images (including old :SHA tags) and containers.
|
||||
docker system prune -af || true
|
||||
# Keep the local BuildKit cache bounded so pip mount cache survives
|
||||
# but stale intermediate layers don't accumulate indefinitely.
|
||||
docker builder prune --keep-storage 5g -f || true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -147,3 +182,10 @@ jobs:
|
||||
provenance: false
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
||||
# Registry-backed layer cache. Pull from :cache to prime
|
||||
# BuildKit, push updated layers back to :cache so the next
|
||||
# build starts warm even if the runner's local cache was
|
||||
# pruned. `mode=max` exports all intermediate layers, not
|
||||
# just the final image, which is what gives the ~80% speedup.
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fabled Assistant
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add content_full and context_prepared caches to rss_items.
|
||||
|
||||
Revision ID: 0038
|
||||
Revises: 0037
|
||||
Create Date: 2026-04-13
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0038"
|
||||
down_revision = "0037"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("rss_items", sa.Column("content_full", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("context_prepared", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("content_fetched_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("rss_items", "content_fetched_at")
|
||||
op.drop_column("rss_items", "context_prepared")
|
||||
op.drop_column("rss_items", "content_full")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Link rss_items to their discussion-summary note.
|
||||
|
||||
Revision ID: 0039
|
||||
Revises: 0038
|
||||
Create Date: 2026-04-14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0039"
|
||||
down_revision = "0038"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("discussion_note_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_rss_items_discussion_note_id",
|
||||
"rss_items",
|
||||
"notes",
|
||||
["discussion_note_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"fk_rss_items_discussion_note_id", "rss_items", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("rss_items", "discussion_note_id")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""rename briefing_date to day_date and drop existing briefing conversations
|
||||
|
||||
Revision ID: 0040
|
||||
Revises: 0039
|
||||
Create Date: 2026-04-25
|
||||
|
||||
This is a hard-cut migration accompanying the Journal feature. The user
|
||||
has accepted destruction of existing briefing data per the design spec.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0040"
|
||||
down_revision = "0039"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DELETE FROM conversations WHERE conversation_type = 'briefing'")
|
||||
op.alter_column("conversations", "briefing_date", new_column_name="day_date")
|
||||
op.execute("DELETE FROM settings WHERE key = 'briefing_config'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("conversations", "day_date", new_column_name="briefing_date")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""add moments + junction tables + moment embeddings
|
||||
|
||||
Revision ID: 0041
|
||||
Revises: 0040
|
||||
Create Date: 2026-04-25
|
||||
|
||||
People, Places, Tasks, and Notes all live in the `notes` table (distinguished
|
||||
by note_type and is_task). The four junction tables FK to notes(id) but stay
|
||||
separate so per-link-kind queries don't require a discriminator filter.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
|
||||
|
||||
revision = "0041"
|
||||
down_revision = "0040"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"moments",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conversation_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"source_message_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("day_date", sa.Date, nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"recorded_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("raw_excerpt", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"tags",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
sa.Column(
|
||||
"pinned",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_moments_user_day", "moments", ["user_id", "day_date"])
|
||||
op.create_index("ix_moments_user_occurred", "moments", ["user_id", "occurred_at"])
|
||||
|
||||
# Four junction tables, all FK to notes(id). Separate (vs. one merged
|
||||
# table with a discriminator) so per-kind queries don't need a filter.
|
||||
for table_name, fk_col in [
|
||||
("moment_people", "person_id"),
|
||||
("moment_places", "place_id"),
|
||||
("moment_tasks", "task_id"),
|
||||
("moment_notes", "note_id"),
|
||||
]:
|
||||
op.create_table(
|
||||
table_name,
|
||||
sa.Column(
|
||||
"moment_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
fk_col,
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
op.create_index(f"ix_{table_name}_{fk_col}", table_name, [fk_col])
|
||||
|
||||
op.create_table(
|
||||
"moment_embeddings",
|
||||
sa.Column(
|
||||
"moment_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column("user_id", sa.Integer, nullable=False),
|
||||
sa.Column("embedding", JSONB, nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_moment_embeddings_user", "moment_embeddings", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_moment_embeddings_user", table_name="moment_embeddings")
|
||||
op.drop_table("moment_embeddings")
|
||||
for table_name, fk_col in [
|
||||
("moment_notes", "note_id"),
|
||||
("moment_tasks", "task_id"),
|
||||
("moment_places", "place_id"),
|
||||
("moment_people", "person_id"),
|
||||
]:
|
||||
op.drop_index(f"ix_{table_name}_{fk_col}", table_name=table_name)
|
||||
op.drop_table(table_name)
|
||||
op.drop_index("ix_moments_user_occurred", table_name="moments")
|
||||
op.drop_index("ix_moments_user_day", table_name="moments")
|
||||
op.drop_table("moments")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""drop RSS infrastructure (tables + leftover briefing settings)
|
||||
|
||||
Revision ID: 0042
|
||||
Revises: 0041
|
||||
Create Date: 2026-04-26
|
||||
|
||||
Hard-cut removal of the RSS feature. Drops rss_feeds, rss_items,
|
||||
rss_item_reactions, rss_item_embeddings, and clears the leftover
|
||||
briefing-era settings rows that referenced RSS topic preferences.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0042"
|
||||
down_revision = "0041"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop in dependency order — children first.
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_embeddings CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_reactions CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_items CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_feeds CASCADE")
|
||||
op.execute(
|
||||
"DELETE FROM settings WHERE key IN "
|
||||
"('rss_enabled', 'briefing_include_topics', 'briefing_exclude_topics')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No downgrade — this is a destructive cleanup. The original RSS tables
|
||||
# would have to be recreated by replaying migrations 0026, 0028, 0035,
|
||||
# 0038, 0039 manually (and the data would not come back).
|
||||
raise NotImplementedError("RSS feature is permanently removed in 0042")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""replace events.end_dt with duration_minutes (Fable #160)
|
||||
|
||||
Revision ID: 0043
|
||||
Revises: 0042
|
||||
Create Date: 2026-04-29
|
||||
|
||||
Structural fix for the "end before start" bug class observed on prod
|
||||
2026-04-29: an event landed with end_dt 32 days before start_dt due
|
||||
to a tool-call mishap, then disappeared from upcoming-list filters.
|
||||
Storing duration instead of end_dt makes the invalid state
|
||||
inexpressible at the schema level (duration_minutes >= 0).
|
||||
|
||||
Backfill rules:
|
||||
- end_dt valid (end_dt > start_dt) → duration_minutes = total minutes
|
||||
- end_dt == start_dt → duration_minutes = 0 (zero-duration point)
|
||||
- end_dt NULL OR end_dt < start_dt → duration_minutes = NULL (corrupt
|
||||
or open-ended; treated as a point event from here on)
|
||||
|
||||
Existing API consumers continue to receive `end_dt` in responses — the
|
||||
field is now derived from `start_dt + duration_minutes` in
|
||||
``Event.to_dict()`` rather than stored.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0043"
|
||||
down_revision = "0042"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("duration_minutes", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"events_duration_minutes_non_negative",
|
||||
"events",
|
||||
"duration_minutes IS NULL OR duration_minutes >= 0",
|
||||
)
|
||||
# Backfill: convert valid end_dt into a minute count; leave NULL for
|
||||
# corrupt or absent end_dt. Bad rows (end_dt <= start_dt) collapse
|
||||
# cleanly to point events instead of forcing a recovery guess.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET duration_minutes = CAST(
|
||||
EXTRACT(EPOCH FROM (end_dt - start_dt)) / 60 AS INTEGER
|
||||
)
|
||||
WHERE end_dt IS NOT NULL AND end_dt >= start_dt
|
||||
"""
|
||||
)
|
||||
op.drop_column("events", "end_dt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("end_dt", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# Restore end_dt = start_dt + duration_minutes minutes for rows that
|
||||
# had a duration. NULL duration → NULL end_dt (point event).
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET end_dt = start_dt + (duration_minutes || ' minutes')::interval
|
||||
WHERE duration_minutes IS NOT NULL
|
||||
"""
|
||||
)
|
||||
op.drop_constraint("events_duration_minutes_non_negative", "events")
|
||||
op.drop_column("events", "duration_minutes")
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fabled Assistant — Quick Start
|
||||
# Fabled Scribe — Quick Start
|
||||
#
|
||||
# No build required. Pulls the latest pre-built image from the registry.
|
||||
#
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
services:
|
||||
app:
|
||||
image: git.fabledsword.com/bvandeusen/fabledassistant:latest
|
||||
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Agentic Briefing — Design Spec
|
||||
|
||||
**Date:** 2026-04-10
|
||||
**Status:** Proposed
|
||||
**Author:** bvandeusen + Claude
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The current briefing pipeline hallucinates calendar events, tasks, and news items that do not exist in the user's actual data. Observed examples from production:
|
||||
|
||||
- A morning briefing asserting "your dentist appointment is still in progress" when no such event existed
|
||||
- An 8am check-in mentioning "a quick meeting at 10:30 AM" with no backing calendar entry
|
||||
- A midday check-in inventing "a team huddle at 2:30 PM" and "the Q2 budget draft due by Friday"
|
||||
- The model calling `search_images` in response to a clarifying question about a fabricated meeting
|
||||
|
||||
These are not bugs in data retrieval — the data gathering code (`_gather_internal`, `_gather_external`) returns correct values. They are a structural consequence of how the briefing context is assembled.
|
||||
|
||||
## Root cause — the "no receipt" problem
|
||||
|
||||
The current `run_compilation` pipeline does this:
|
||||
|
||||
1. Python code gathers data (tasks, events, weather, news) from the database
|
||||
2. Python formats the data into a structured text blob as the user-role message: `TODAY'S EVENTS: ...`, `DUE TODAY: ...`, etc.
|
||||
3. The LLM is called **once** with `[system_prompt, user_prompt]` and produces prose
|
||||
4. Only the prose reply is written to the conversation. **The underlying data is never persisted in the conversation history.**
|
||||
|
||||
When the user later chats in the briefing conversation, the chat endpoint loads the full conversation history — which contains the model's *prose* from earlier but not the data that prose was derived from. The model's own prior output becomes the only source of "truth" available for follow-up questions. If that prose asserted a fact (real or hallucinated), the model has no way to distinguish it from ground truth when generating the next reply, and it will double down.
|
||||
|
||||
Compounding factors:
|
||||
|
||||
- **Empty sections are silently omitted.** If `calendar_events` is empty, the user prompt contains no `TODAY'S EVENTS:` line at all. The model has no explicit "zero events" signal — combined with an imperative system prompt ("note calendar events and tasks"), it interprets the silence as "I should mention some" and fabricates.
|
||||
- **Scheduled slot injections append synthetic turns.** `run_slot_injection` writes a fake `[Midday briefing update]` user message and the assistant reply into the persistent conversation. By evening, the chat history contains three separate briefings, each potentially with errors, all treated as equal-weight context on follow-up.
|
||||
- **The `search_images` tool is available during briefing chat**, with a negative-instruction description ("Not for factual questions"). Small and mid-sized models frequently ignore negative guidance in tool descriptions and call the tool anyway.
|
||||
|
||||
## Solution — agentic briefing (the receipt model)
|
||||
|
||||
Replace the one-shot synthesis with a tool-call loop. The briefing is no longer "a text blob synthesized from pre-gathered data." It is **a scheduled agent run**: the LLM is given a system prompt, a curated set of read-only data tools, and a trigger ("generate the morning briefing"). The model must call tools to see what exists. Every tool call and tool result becomes part of the conversation history, where it lives as a permanent, structured receipt.
|
||||
|
||||
### Why this fixes the hallucination
|
||||
|
||||
When the model calls `list_events(today) → []`, that empty array is now a persistent message in the conversation. On a follow-up question like "what meeting?", the chat endpoint loads the conversation and the model sees its own tool result from the morning showing no events. Answering "you had a meeting at 10:30" would require the model to directly contradict a tool result sitting two messages back — something LLMs are much more reliable at avoiding than contradicting their own prior prose.
|
||||
|
||||
In short: **the model cannot fabricate what has a visible receipt proving it does not exist.**
|
||||
|
||||
### What the model sees — before vs after
|
||||
|
||||
**Before** — the model sees one structured blob and produces prose. The blob is discarded.
|
||||
|
||||
```
|
||||
[system] You are a personal assistant... weave together what matters...
|
||||
[user] Date: 2026-04-10
|
||||
WEATHER: Home — partly cloudy, 8–16°C
|
||||
DUE TODAY: Fix briefing hallucinations
|
||||
OVERDUE (2 tasks): ...
|
||||
[assistant] Morning! Looks like a quiet day with two overdue items...
|
||||
```
|
||||
|
||||
**After** — the model is given tools and iterates to ground truth.
|
||||
|
||||
```
|
||||
[system] You are the user's personal assistant giving their morning briefing.
|
||||
Use tools to see what's relevant. Only mention things you learned from
|
||||
tool calls. If a tool returns nothing, say so honestly.
|
||||
[user] Generate the morning briefing.
|
||||
[assistant] tool_call: list_tasks(filter="due_today")
|
||||
[tool] [{"id": 81, "title": "Fix briefing hallucinations", ...}]
|
||||
[assistant] tool_call: list_tasks(filter="overdue")
|
||||
[tool] [{"id": 42, ...}, {"id": 43, ...}]
|
||||
[assistant] tool_call: list_events(today)
|
||||
[tool] []
|
||||
[assistant] tool_call: get_weather(location="home")
|
||||
[tool] {"description": "partly cloudy", "temp_min": 8, "temp_max": 16}
|
||||
[assistant] tool_call: get_rss_items(max=8)
|
||||
[tool] [...]
|
||||
[assistant] Morning! Partly cloudy today, 8 to 16 — nothing on the calendar
|
||||
so it's a clean run at the desk. Two things to keep in mind...
|
||||
```
|
||||
|
||||
The conversation now contains verifiable receipts: `list_events(today)` returned `[]`, and that result sits in context forever (until pruned). Follow-up questions operate against those receipts.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Existing infrastructure (reused, not rebuilt)
|
||||
|
||||
The codebase already has the agentic primitives — they're used for regular chat:
|
||||
|
||||
- **`llm.py::stream_chat_with_tools`** — the streaming tool-use loop that talks to Ollama with a `tools` parameter and yields tool-call chunks
|
||||
- **`generation_task.py::_stream_with_retry`** — wraps `stream_chat_with_tools` with retry-on-500 behavior for cold-model races
|
||||
- **`tools.py`** — defines 40+ tool schemas and an execution dispatcher
|
||||
|
||||
The briefing bypasses all of this and calls a one-shot `_llm_synthesise` helper. The refactor is mainly "route briefings through the same pipeline regular chat already uses."
|
||||
|
||||
### New modules
|
||||
|
||||
**`briefing_tools.py`** — a small wrapper exposing a curated read-only subset of `tools.py` for briefing runs. This is an **explicit allowlist**, not a blocklist, so newly-added tools must be opted in:
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `list_tasks` (with filter args) | See what's actionable today, overdue, high priority |
|
||||
| `list_events` (today / upcoming) | Know what's on the calendar |
|
||||
| `get_weather` | Current/forecast weather |
|
||||
| `get_rss_items` | Pull news themes filtered by user preferences |
|
||||
| `list_projects` | Understand project context |
|
||||
| `search_projects` | Surface active project summaries |
|
||||
| `list_notes` (recent) | Capture follow-ups from yesterday |
|
||||
|
||||
**Explicitly omitted** from the briefing tool set:
|
||||
|
||||
- `search_images`, `search_web`, `research_topic`, `read_article` — external search is not a briefing concern, and `search_images` is the source of the "Peter Kyle Science Secretary" image-search bug
|
||||
- All `create_*`, `update_*`, `delete_*` — briefings are read-only; a scheduled background job must not decide to mutate the user's data on its own
|
||||
- `set_rag_scope`, `calculate` — not relevant to briefing content
|
||||
|
||||
### New briefing function
|
||||
|
||||
**`briefing_pipeline.py::run_agentic_briefing(user_id, slot, model, conversation_id)`** replaces `run_compilation`'s body (and eventually `run_slot_injection`). Internally:
|
||||
|
||||
1. Build a slot-specific system prompt (see below)
|
||||
2. Load the curated briefing tools from `briefing_tools.py`
|
||||
3. Seed messages with `[system, user]` where the user message is a simple trigger like `"Generate the morning briefing."`
|
||||
4. Enter a tool-call loop (max 8 iterations):
|
||||
- Call `stream_chat_with_tools`
|
||||
- If the model returns `tool_calls`, execute them via the existing dispatcher, append tool results, continue
|
||||
- If the model returns a final assistant message with no pending tool calls, break
|
||||
5. Return `(final_prose, full_message_list, metadata)`
|
||||
|
||||
The full message list is important: it's written to the conversation along with the final prose, so the tool-call receipts become part of the persistent record.
|
||||
|
||||
### Slot-specific system prompts
|
||||
|
||||
Compilation (full morning briefing):
|
||||
|
||||
```
|
||||
You are the user's personal assistant giving their full morning briefing.
|
||||
Use the tools available to see what's actually relevant today — tasks due,
|
||||
overdue items, events on the calendar, weather, news themes, project state
|
||||
— and weave it into a warm, natural-sounding summary.
|
||||
|
||||
Rules:
|
||||
- Call tools to see the data. Never assert facts you didn't learn from a tool.
|
||||
- If a tool returns nothing (no events today, no overdue tasks), say so
|
||||
honestly. Don't fabricate items to fill space.
|
||||
- Write flowing prose. No markdown, no headers, no bullet points.
|
||||
- Aim for 6–10 sentences. Skip topics that have nothing interesting.
|
||||
- Close on one or two concrete, actionable suggestions.
|
||||
|
||||
User profile (for tone and preferences):
|
||||
{profile_body}
|
||||
```
|
||||
|
||||
Check-ins (midday, afternoon):
|
||||
|
||||
```
|
||||
You are the user's personal assistant giving a brief {slot} check-in.
|
||||
Use tools to see what's changed since this morning. Focus on progress
|
||||
and what's still unaddressed.
|
||||
|
||||
Rules:
|
||||
- Call tools to see current state. Never assert facts without tool results.
|
||||
- If nothing meaningful has changed, say so briefly — don't invent progress.
|
||||
- 3–5 sentences, natural prose, no markdown.
|
||||
```
|
||||
|
||||
### Conversation hygiene — removing the fake user messages
|
||||
|
||||
The current scheduler appends two fake messages on every slot injection:
|
||||
|
||||
```python
|
||||
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
|
||||
await post_message(conv.id, "assistant", text)
|
||||
```
|
||||
|
||||
Under the agentic model, we drop the fake `"user"` message entirely. Slot updates become plain assistant messages tagged with `metadata.briefing_slot = "midday"`. The chat endpoint's message loader is updated to filter these when building the LLM context on follow-ups, so a user chatting in the briefing conversation doesn't see three earlier briefings smashed into their history. The UI continues to show them as visible timeline entries.
|
||||
|
||||
(This is the Option A filter from the earlier discussion — a small, surgical change compared to migrating briefings to a separate sidecar table. Sidecar storage remains a possible future step.)
|
||||
|
||||
### Ollama setup compatibility
|
||||
|
||||
The existing Ollama deployment uses non-parallel mode across two GPUs, with a concern about context duplication between cards. This is the correct setup for agentic briefings:
|
||||
|
||||
- Each tool-call iteration shares the same KV cache on the same GPU, so appending tool results is cheap
|
||||
- There is no race where a second iteration could land on a different card with a different cache state
|
||||
- The trade-off is serialization: a briefing in progress will block a concurrent user chat request until it finishes, but scheduled briefings are rare enough (4× per day) that this is acceptable
|
||||
|
||||
If GPU contention becomes a problem later, the right lever is pinning specific models to specific GPUs (e.g., background tasks on GPU 1, interactive models on GPU 0) — not enabling parallelism.
|
||||
|
||||
## Cost & trade-offs
|
||||
|
||||
- **Latency:** a briefing now makes 5–7 inference calls (one per tool-call decision plus the final prose) instead of 1. On a local Ollama with a 7B model, expect 15–40s per briefing vs the current 5–10s. Acceptable for a background job; if it becomes painful at the user-facing check-in slots, investigate letting the model batch independent tool calls in a single turn (Ollama supports this).
|
||||
- **Model requirements:** the default model must be reliable at tool calling. `qwen2.5:7b`, `llama3.1:8b`, `mistral-small-3:24b`, and similar handle it well. Models in the ≤3B class typically fail — they either emit no tool calls and return empty prose, or hallucinate invalid tool arguments. If a user's `default_model` is too small, agentic mode should fall back to legacy mode with a log warning.
|
||||
- **Context growth:** tool results bloat the conversation. At 8 tool calls per compilation × 4 slots per day, a daily briefing conversation can reach 20-30 KB of JSON-heavy history. Fine for a day; aged briefings should be archived/rolled up after 7 days.
|
||||
- **Tool subset drift:** someone adds a new mutating tool to `tools.py` and forgets to update the briefing allowlist. Mitigated by the allowlist model — the default for new tools is "not exposed to briefings."
|
||||
- **Infinite loop safety:** a buggy model could tool-call forever. Hard cap at 8 iterations, log a warning if hit, return whatever prose was last produced (or a fallback message).
|
||||
|
||||
## Migration path
|
||||
|
||||
Ship incrementally, each step independently reversible:
|
||||
|
||||
**PR 1 — Agentic compilation behind a feature flag.** Add `briefing_tools.py`, add `run_agentic_briefing`, add a per-user setting `briefing_mode: "legacy" | "agentic"` (default legacy). Route only the 4am compilation through the new path when the flag is set. Keep slot-injection on the legacy path. Enable the flag on the author's account first, validate output quality over several days, then flip the default for all users. No DB migration required — the setting lives in the existing `settings` table.
|
||||
|
||||
**PR 2 — Agentic slot injections + conversation hygiene.** Migrate midday/afternoon check-ins to the same pipeline. Remove the fake `[Midday briefing update]` user-role message; slot updates become plain assistant messages tagged with `metadata.briefing_slot`. Add a chat-message-loader filter that excludes slot-tagged messages from the LLM context on follow-ups (they remain visible in the UI).
|
||||
|
||||
**PR 3 — UI polish.** Collapsed tool-call status row in the briefing card ("✓ checked calendar · ✓ looked at tasks · ✓ pulled weather"), expanding to show tool results on click. Tool-result cards (weather, news, task list) rendered inline where useful.
|
||||
|
||||
**PR 4 (optional) — Sidecar storage for briefing snapshots.** If the chat-filter approach in PR 2 feels too hacky, migrate briefings out of the conversations table and into a dedicated `briefing_snapshots` table. Frontend renders them as pinned timeline cards separate from chat. Larger refactor; defer until PR 1–3 prove the approach works.
|
||||
|
||||
## Secondary win — tightening the main chat system prompt
|
||||
|
||||
The regular chat is already agentic (it uses `stream_chat_with_tools`), but its system prompt does not explicitly require the model to ground factual claims in tool results. The prompt discipline introduced for briefings — *"Never assert facts you didn't learn from a tool. If a tool returns nothing, say so honestly."* — is worth applying to the main chat system prompt in a follow-up PR. The mechanism already works; only the framing needs tightening.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The Android Flutter client's failure to render `search_images` tool-result cards. This is a separate rendering gap in the mobile app and does not affect the server-side fix. Tracked separately.
|
||||
- Re-evaluating the Ollama parallelism setting. The current non-parallel config is correct for this work.
|
||||
- Replacing the background model for title/summary/observation extraction. That model's role is unrelated to briefings.
|
||||
+13
-14
@@ -145,23 +145,22 @@ All endpoints require login (session cookie or `Authorization: Bearer <api-key>`
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
|
||||
|
||||
## Briefing
|
||||
## Journal
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/briefing/config` | Get briefing configuration |
|
||||
| PUT | `/api/briefing/config` | Save briefing configuration |
|
||||
| GET | `/api/briefing/feeds` | List RSS feeds |
|
||||
| POST | `/api/briefing/feeds` | Add RSS feed `{url, name?, category?}` |
|
||||
| DELETE | `/api/briefing/feeds/:id` | Delete feed |
|
||||
| POST | `/api/briefing/feeds/refresh` | Trigger immediate feed refresh → `{feeds_refreshed, new_items}` |
|
||||
| GET | `/api/briefing/weather` | Get weather configuration |
|
||||
| PUT | `/api/briefing/weather` | Save weather locations |
|
||||
| POST | `/api/briefing/weather/geocode` | Geocode address `{query}` → `{lat, lon, label}` |
|
||||
| POST | `/api/briefing/trigger` | Manually fire a briefing slot `{slot}` |
|
||||
| GET | `/api/briefing/conversations` | List past briefing conversations |
|
||||
| GET | `/api/briefing/conversations/today` | Get/create today's briefing conversation |
|
||||
| GET | `/api/briefing/conversations/:id/messages` | Get messages for a briefing conversation |
|
||||
| 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
|
||||
|
||||
|
||||
+30
-32
@@ -133,11 +133,11 @@ Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
|
||||
|
||||
### Settings
|
||||
|
||||
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `briefing_enabled`, `briefing_locations`, `office_days`, etc.
|
||||
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `journal_config` (JSON: locations, temp_unit, prep schedule), `user_timezone`, `voice_*`, etc.
|
||||
|
||||
### Conversations / Messages
|
||||
|
||||
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`briefing`/`mcp`), `briefing_date`, `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
|
||||
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`journal`/`mcp`), `day_date` (YYYY-MM-DD for journal conversations), `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
|
||||
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
|
||||
|
||||
Title auto-generated by LLM on first exchange, re-generated every 10th message.
|
||||
@@ -155,11 +155,9 @@ Title auto-generated by LLM on first exchange, re-generated every 10th message.
|
||||
|
||||
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
|
||||
|
||||
### Briefing-Related Tables
|
||||
### Journal-Related Tables
|
||||
|
||||
`rss_feeds`: `id`, `user_id`, `url`, `name`, `category`, `last_fetched_at`.
|
||||
`rss_items`: `id`, `feed_id` FK, `guid`, `title`, `url`, `summary`, `pub_date`.
|
||||
`weather_cache`: per-user cache with `lat`, `lon`, `location_name`, `forecast_json`, `fetched_at`.
|
||||
`weather_cache`: per-user, per-`location_key` cache. Columns: `user_id`, `location_key` (`home`/`work`/etc.), `location_label`, `forecast_json` (Open-Meteo response), `previous_json` (last forecast, used to detect changes), `fetched_at`. Lat/lon are *not* stored on the cache row — they live in the user's `journal_config.locations.{home|work}` setting and are used at refresh time.
|
||||
|
||||
### API Keys
|
||||
|
||||
@@ -188,14 +186,14 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
|
||||
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
|
||||
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
|
||||
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm; briefing conversation routes |
|
||||
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm |
|
||||
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
|
||||
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
|
||||
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
|
||||
| `routes/projects.py` | Projects CRUD + summary endpoint |
|
||||
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
|
||||
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
|
||||
| `routes/briefing.py` | Briefing conversation + reply + history; RSS feed management |
|
||||
| `routes/journal.py` | Journal config CRUD; today/day/days conversation accessors; weather endpoints (cached/current/refresh/geocode); moments CRUD; trigger-prep |
|
||||
| `routes/groups.py` | Group CRUD + membership management (admin) |
|
||||
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
|
||||
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
|
||||
@@ -212,8 +210,8 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `services/api_keys.py` | `generate_key()`, `create_api_key()`, `list_api_keys()`, `revoke_api_key()`, `lookup_key()` (SHA-256 hash lookup) |
|
||||
| `services/llm.py` | `build_context()`, RAG injection, history summarisation, `stream_chat_with_tools()`, URL fetching, SSRF guard |
|
||||
| `services/generation_task.py` | `run_generation()` — full chat pipeline: intent routing, tool loop, SSE fan-out, push notification; `run_assist_generation()` |
|
||||
| `services/intent.py` | `classify_intent()` — fast non-streaming LLM call; intent skip heuristic; `_PRIOR_WORK_REFS` fast-path |
|
||||
| `services/tools.py` | All LLM tool definitions + `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` dispatcher; duplicate guards; `_resolve_project()` 4-step lookup; `search_projects` and `set_rag_scope` tools |
|
||||
| ~~`services/intent.py`~~ | Removed — intent routing eliminated; the main model handles all tool routing directly |
|
||||
| `services/tools/` | LLM tool package — decorator-based registry (`_registry.py`), shared helpers (`_helpers.py`), and one module per domain: `notes.py` (create/update/delete/search/list/read), `tasks.py` (list/log_work), `entities.py` (save_person/save_place/lists), `projects.py`, `calendar.py`, `web.py`, `rag.py`, `profile.py`, `rss.py`, `weather.py`, `utility.py` (calculate). `execute_tool()` and `get_tools_for_user()` are the public API. |
|
||||
| `services/projects.py` | Project CRUD + `generate_project_summary()` (Ollama, fire-and-forget) + `backfill_project_summaries()` (startup) |
|
||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes(orphan_only=False)` (pgvector cosine similarity) |
|
||||
| `services/generation_buffer.py` | In-memory SSE event buffer; `cancel_event`; 60s cleanup; supports both chat (int keys) and assist (string keys) |
|
||||
@@ -227,11 +225,12 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `services/groups.py` | Group CRUD; membership management |
|
||||
| `services/notifications.py` | Create/read/mark-read in-app notifications |
|
||||
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
|
||||
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → `GenerationBuffer` stream |
|
||||
| `services/briefing_scheduler.py` | APScheduler `BackgroundScheduler`; slots with catch-up logic; async-safe via `asyncio.create_task` |
|
||||
| `services/briefing_conversations.py` | Briefing conversation persistence and history queries |
|
||||
| `services/briefing_profile.py` | Per-user profile note that the assistant updates over time |
|
||||
| `services/research.py` | SearXNG research pipeline: 5 sub-queries → parallel fetch → synthesis; `search_images` for image category |
|
||||
| `services/journal_prep.py` | Deterministic data gather (tasks/events/weather/projects/recent moments/open threads) → LLM prose opener; persisted as the first assistant message of today's journal Conversation |
|
||||
| `services/journal_pipeline.py` | System-prompt builder for journal conversations; calls `build_profile_context()` so the LLM sees the user's profile + learned summary |
|
||||
| `services/journal_scheduler.py` | APScheduler `BackgroundScheduler`; per-user prep job; live-reschedule via `update_user_schedule()`; catch-up logic for missed runs |
|
||||
| `services/journal_search.py`, `services/moments.py` | Moment recording + search across journal history |
|
||||
| `services/user_profile.py` | `build_profile_context()` consolidates profile + observations + learned_summary into a system-prompt block |
|
||||
| `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes |
|
||||
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
|
||||
| `routes/events.py` | `/api/events` — event CRUD routes |
|
||||
| `services/caldav.py` | Optional CalDAV sync — user-configured external server; syncs to/from internal store via `caldav_uid` FK; `is_caldav_configured()` guards tool activation |
|
||||
@@ -291,10 +290,10 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `services/access.py` | Permission resolution for all shared resources |
|
||||
| `services/llm.py` | `build_context()`, RAG injection, history summarisation |
|
||||
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
|
||||
| `services/tools.py` | All LLM tool implementations (`create_note`, `search_notes`, `get_weather`, …) |
|
||||
| `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry |
|
||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
|
||||
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → briefing output |
|
||||
| `services/briefing_scheduler.py` | APScheduler integration, catch-up logic for missed slots |
|
||||
| `services/journal_prep.py` | Data gather → LLM prose opener; persisted into today's journal conversation |
|
||||
| `services/journal_scheduler.py` | APScheduler integration, per-user prep job, catch-up for missed runs |
|
||||
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
|
||||
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
|
||||
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
|
||||
@@ -311,26 +310,22 @@ See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
|
||||
|
||||
## LLM Pipeline Internals
|
||||
|
||||
### Intent Routing
|
||||
### Tool Routing
|
||||
|
||||
Before the main model runs, a lightweight intent classifier (`services/intent.py`) runs concurrently with `build_context()`. It makes a fast non-streaming call using a smaller dedicated model (`OLLAMA_INTENT_MODEL`, default `qwen2.5:7b`) to determine if the message requires a tool call.
|
||||
|
||||
**Skip heuristic** — Intent classification is skipped entirely for short messages (≤10 words) with no action/object keywords, saving 400–800ms on conversational replies.
|
||||
|
||||
**Prior-work fast-path** — `_PRIOR_WORK_REFS` regex detects phrases like "research you did", "note you made", "using your research" and returns no-tool immediately, preventing `search_web` from firing when the user references existing notes.
|
||||
|
||||
If a tool is detected, the intent's one-sentence `ack` field is streamed as the first chunk (TTFT), the tool executes, then the main model generates a follow-up with the tool result. For chat-only responses the main model streams directly.
|
||||
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. Extended reasoning (`think=True`) is always on for qwen3-class models: content-based gating was tried but exposed tool-call template fragility on short tool-intent prompts.
|
||||
|
||||
### Tool Loop
|
||||
|
||||
Multi-round tool loop (max 5 rounds). All implementations in `services/tools.py`; `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
|
||||
Multi-round tool loop (max 5 rounds). All implementations in `services/tools/` (decorator-based registry); `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
|
||||
|
||||
**Duplicate protection on `create_note` / `create_task`:**
|
||||
**Unified `create_note` tool** — creates both notes and tasks. Setting `status` (e.g. `"todo"`) creates a task; omitting it creates a knowledge note. All task fields (due_date, priority, milestone, parent_task, recurrence_rule) are available on the single tool.
|
||||
|
||||
**Duplicate protection on `create_note`:**
|
||||
1. Exact title match (case-insensitive) → hard block, redirect to `update_note`
|
||||
2. Fuzzy title match (SequenceMatcher ≥ 82%; punctuation stripped before candidate search) → hard block
|
||||
3. Semantic content similarity (threshold 0.90, body ≥ 200 chars) → soft block with `requires_confirmation: true`
|
||||
3. Semantic content similarity (threshold 0.90, body ≥ 80 chars) → soft block with `requires_confirmation: true`
|
||||
|
||||
**Project resolution** (`_resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
|
||||
**Project resolution** (`_helpers.resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
|
||||
|
||||
### Context Window and Summarisation
|
||||
|
||||
@@ -344,8 +339,11 @@ History summarisation threshold: 30 messages. Keeps 8 recent messages. Summary m
|
||||
1. Intent model generates 5 focused sub-queries
|
||||
2. All 5 SearXNG queries run in parallel (200ms stagger to avoid rate limiter)
|
||||
3. Up to 15 unique URLs fetched in parallel
|
||||
4. Up to 12 sources passed to synthesis LLM
|
||||
5. Result saved as a note with `tags=["research"]`
|
||||
4. LLM generates an outline (2–8 sections with title + focus)
|
||||
5. Each section synthesised in parallel from relevant sources
|
||||
6. Executive summary generated from all section content
|
||||
7. Index note created with executive summary + links to section notes; section notes linked back via `parent_id`
|
||||
8. Falls back to single-note synthesis if outline generation fails
|
||||
|
||||
SearXNG tip: add the app server IP to `botdetection.ip_lists.pass_ip` in SearXNG `settings.yml` to bypass the rate limiter for trusted backend requests.
|
||||
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
# FabledSword Design System
|
||||
|
||||
A house-style design system for the FabledSword family of self-hosted applications. FabledSword is the umbrella identity; individual apps share a common visual language but each carries its own signature accent color.
|
||||
|
||||
## Brand model
|
||||
|
||||
FabledSword is a **house style**, not a single brand. Apps share:
|
||||
|
||||
- Typography
|
||||
- Surfaces and dark-mode foundation
|
||||
- Component shapes (pills, cards, buttons)
|
||||
- Spacing system
|
||||
- Semantic colors (success, warning, error, info)
|
||||
- Action button colors (moss primary, bronze secondary)
|
||||
- Voice and tone
|
||||
|
||||
Each public app has its own **signature accent** used for: the wordmark, the app icon, active nav state, "you are here" indicators, cursor/selection color, and key brand moments. Accents do **not** appear on action buttons — those stay system-wide.
|
||||
|
||||
## Aesthetic direction
|
||||
|
||||
Modern mythic with heraldic restraint. Tech-forward execution, but the visual language borrows from manuscripts, heraldry, and forged objects rather than from gaming or fantasy iconography. Dark-mode-first because that's where these apps live.
|
||||
|
||||
The reference points: a well-printed book, a well-kept armory, a steward's ledger. Not: a fantasy novel cover, a tabletop RPG character sheet, a Renaissance Faire poster.
|
||||
|
||||
---
|
||||
|
||||
## Color system
|
||||
|
||||
### Universal surfaces (dark mode foundation)
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Obsidian | `#14171A` | Page background, deepest surface |
|
||||
| Iron | `#1E2228` | Card surfaces, raised elements |
|
||||
| Slate | `#2C313A` | Hovered surfaces, secondary elevation |
|
||||
| Pewter | `#3F4651` | Borders, dividers, ghost button outlines |
|
||||
|
||||
### Universal text and parchment
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Parchment | `#E8E4D8` | Primary text on dark surfaces |
|
||||
| Vellum | `#C2BFB4` | Secondary text, captions |
|
||||
| Ash | `#9C9A92` | Tertiary text, hints, metadata |
|
||||
|
||||
Parchment is intentionally not pure white — it's slightly warm to feel like aged paper. Pure white (#FFFFFF) is never used as text color.
|
||||
|
||||
### Universal action colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Moss | `#4A5D3F` | Primary action buttons (Save, Submit, Confirm) |
|
||||
| Bronze | `#8B7355` | Secondary action buttons (Cancel-but-not-destructive, alternate paths) |
|
||||
| Pewter | `#3F4651` | Tertiary/ghost buttons |
|
||||
|
||||
**Critical rule:** Action button colors are universal across all apps. A Save button in Scribe and a Save button in Minstrel look identical. Per-app accents do not appear on buttons.
|
||||
|
||||
### Semantic colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Success | `#4A5D3F` | Success states (same as Moss — they're aligned by design) |
|
||||
| Warning | `#8B6F1E` | Warnings, caution states |
|
||||
| Error | `#C04A1F` | Error messages, validation failures |
|
||||
| Info | `#3D5A6E` | Informational callouts |
|
||||
| Destructive | `#6B2118` | Destructive action buttons (Delete, Remove, irreversible) |
|
||||
|
||||
**Why error and destructive are different:** Error is the orange-red used in alerts and validation messages. Destructive (oxblood) is reserved for buttons that perform irreversible actions — it carries more weight precisely because it's used sparingly. Pair destructive buttons with an icon (trash, X) so color is reinforcement, not the only signal.
|
||||
|
||||
### Per-app signature accents
|
||||
|
||||
| App | Hex | Mood |
|
||||
|---|---|---|
|
||||
| Fabled Scribe | `#5B4A8A` | Dusty violet — ink, manuscripts |
|
||||
| Minstrel | `#4A6B5C` | Forest teal — music, performance |
|
||||
| Fabled Forge | `#8B5A2B` | Forge bronze — creation, craft |
|
||||
| Roundtable | `#4A5D7E` | Slate blue — stewardship, infrastructure |
|
||||
| FabledSword (umbrella) | `#6B2118` | Oxblood — house identity, ceremonial use only |
|
||||
|
||||
**Accent usage rules:**
|
||||
- The accent appears on the app's wordmark and icon.
|
||||
- The accent indicates active/current state in nav (the selected page, the active tab).
|
||||
- The accent is the cursor color and text-selection color in long-form surfaces (Scribe notes, Forge story drafts).
|
||||
- The accent does NOT appear on primary or secondary action buttons.
|
||||
- The accent does NOT appear in body text or chrome.
|
||||
- One accent per app. Don't mix accents within a single app.
|
||||
|
||||
### Color contrast
|
||||
|
||||
All text-on-surface combinations meet WCAG AA at minimum. Parchment on Obsidian is the maximum-contrast pairing; Vellum on Iron is the lowest-contrast pairing still considered acceptable for body text. Ash is for hints only — never load-bearing information.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Type families
|
||||
|
||||
| Family | Role | Source |
|
||||
|---|---|---|
|
||||
| Fraunces | Display, headings, wordmarks | Google Fonts |
|
||||
| Inter | Body, UI, labels | Google Fonts |
|
||||
| JetBrains Mono | Code, terminal output, monospaced data | Google Fonts |
|
||||
|
||||
### Why this pairing
|
||||
|
||||
Fraunces is a contemporary serif with personality — it has the warmth and authority of a book serif without feeling like costume. It signals "this is considered" without signaling "this is a fantasy product." Inter is the workhorse — neutral, ubiquitous, designed for screens, doesn't compete with the serif. JetBrains Mono is the natural choice for any developer-adjacent product and supports ligatures.
|
||||
|
||||
### Type scale
|
||||
|
||||
| Token | Size | Weight | Family | Usage |
|
||||
|---|---|---|---|---|
|
||||
| Display | 40px | 500 | Fraunces | App wordmark, hero text |
|
||||
| H1 | 32px | 500 | Fraunces | Page titles |
|
||||
| H2 | 24px | 500 | Fraunces | Section headings |
|
||||
| H3 | 18px | 500 | Inter | Subsection headings, card titles |
|
||||
| Body | 15px | 400 | Inter | Paragraphs, default text |
|
||||
| Body small | 13px | 400 | Inter | Captions, metadata |
|
||||
| Label | 12px | 500 | Inter | Buttons, form labels, badges |
|
||||
| Code | 13px | 400 | JetBrains Mono | Inline code, code blocks |
|
||||
| Tiny | 11px | 500 | Inter | Micro-labels (`UPPERCASE LETTERSPACED`) |
|
||||
|
||||
### Typography rules
|
||||
|
||||
- **Sentence case everywhere.** Never Title Case for headings, never ALL CAPS except for the Tiny micro-label style.
|
||||
- **Two weights only:** 400 regular and 500 medium. Never 600 or 700 — they read heavy in dark mode.
|
||||
- **Fraunces only at 18px and above.** Below that it loses too much detail and feels fragile. For h3 and below, use Inter.
|
||||
- **Line height** 1.5 for body, 1.3 for headings, 1.7 for long-form reading surfaces (Scribe notes, Forge drafts).
|
||||
- **Letter-spacing** at default for everything except the Tiny micro-label, which gets `0.08em` letter-spacing and uppercase styling.
|
||||
|
||||
---
|
||||
|
||||
## Spacing and layout
|
||||
|
||||
### Spacing scale (px)
|
||||
|
||||
`4, 8, 12, 16, 20, 24, 32, 48, 64, 96`
|
||||
|
||||
Use rem units for vertical rhythm in long-form content (paragraph spacing). Use px for component-internal spacing (padding, gaps).
|
||||
|
||||
### Border radius
|
||||
|
||||
| Token | Size | Usage |
|
||||
|---|---|---|
|
||||
| Small | 4px | Pills, tags, code spans |
|
||||
| Medium | 8px | Buttons, inputs, small cards |
|
||||
| Large | 12px | Cards, panels, modals |
|
||||
| Extra large | 16px | Hero containers, major surfaces |
|
||||
|
||||
### Borders
|
||||
|
||||
- Default border: `0.5px solid Pewter` (#3F4651)
|
||||
- Hovered/emphasized border: `0.5px solid Vellum` at 30% opacity
|
||||
- Featured/active border: `2px solid [accent]` (only for emphasizing a selected card or active tab)
|
||||
|
||||
The 0.5px default is deliberate — it reads as a hairline at most pixel densities and avoids the heavy "boxed-in" feeling that 1px+ borders create on dark backgrounds.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Buttons
|
||||
|
||||
| Variant | Background | Text | Border |
|
||||
|---|---|---|---|
|
||||
| Primary | Moss `#4A5D3F` | Parchment | None |
|
||||
| Secondary | Bronze `#8B7355` | Parchment | None |
|
||||
| Ghost | Transparent | Parchment | 0.5px Pewter |
|
||||
| Destructive | Oxblood `#6B2118` | Parchment | None — pair with icon |
|
||||
|
||||
Padding: `8px 16px` for default, `6px 12px` for compact, `10px 20px` for prominent. Border-radius: 8px. Font: Inter 12px/500 with default letter-spacing.
|
||||
|
||||
### Pills and tags
|
||||
|
||||
Used for tags, hashtags, code spans, status badges. Background is the accent color at ~15% opacity, text is the accent at full strength. Border-radius 4px, padding `2px 8px`, Inter 11px/500.
|
||||
|
||||
In Scribe specifically, hashtags and tags use the dusty violet accent. In Minstrel, they'd use forest teal. The pattern is shared; the color follows the app.
|
||||
|
||||
### Cards
|
||||
|
||||
- Background: Iron (#1E2228)
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 12px
|
||||
- Padding: 20px
|
||||
|
||||
For featured/selected cards, swap to a 2px solid accent border. Don't change the background.
|
||||
|
||||
### Inputs
|
||||
|
||||
- Background: Obsidian (#14171A) — darker than the page surface to feel "inset"
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 8px
|
||||
- Padding: 8px 12px
|
||||
- Focus state: 2px solid accent ring (using `box-shadow: 0 0 0 2px [accent]` to avoid layout shift)
|
||||
|
||||
### Code blocks
|
||||
|
||||
- Background: Obsidian (#14171A)
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 8px
|
||||
- Padding: 12px 16px
|
||||
- Font: JetBrains Mono 13px/400
|
||||
- Inline code: same family, with 4px-radius pill background using the app accent at 15% opacity
|
||||
|
||||
---
|
||||
|
||||
## The FabledSword lockup
|
||||
|
||||
A small, persistent FS mark appears in the navigation chrome of every app — the way an Apple logo persists across macOS apps. This is the only place oxblood appears in normal app usage.
|
||||
|
||||
**Specification:**
|
||||
- 16-20px height in nav contexts
|
||||
- Oxblood (#6B2118) on dark surfaces
|
||||
- Positioned in the bottom-left of nav rails or top-left when there's no rail
|
||||
- Hover/click reveals a small menu: link to other apps in the family, link to FabledSword.com, version info
|
||||
|
||||
The lockup itself is a small heraldic mark — a stylized FS monogram — *not* a literal sword icon. We're avoiding sword imagery in app chrome because it would clash with the restrained, modern-mythic aesthetic. The wordmark "FabledSword" appears only on the umbrella site and in About/Settings dialogs.
|
||||
|
||||
---
|
||||
|
||||
## Voice and tone
|
||||
|
||||
The FabledSword voice is **understated mythic** — it borrows the register of stewardship, craft, and considered making, but never tips into roleplay or affectation.
|
||||
|
||||
### Do
|
||||
|
||||
- Use plain language for everything functional. ("Save", "Cancel", "Add note")
|
||||
- Reserve flavored language for moments where the user is *waiting* or *failing* — loading states, empty states, error pages, 404s.
|
||||
- Borrow vocabulary from craft and stewardship: "draft", "ledger", "kept", "set aside", "to come", "in progress", "abandoned".
|
||||
- Be brief. The mythic register is undermined by verbosity.
|
||||
|
||||
### Don't
|
||||
|
||||
- Don't use thee/thou/thy or pseudo-archaic spelling.
|
||||
- Don't address the user as "traveler", "wanderer", "adventurer", or any RPG-adjacent epithet.
|
||||
- Don't use sword/blade/forge metaphors in error messages. ("Your save was forged successfully" — no.)
|
||||
- Don't make the user feel like they're playing a game when they're just trying to use software.
|
||||
|
||||
### Examples
|
||||
|
||||
| Context | Plain | FabledSword voice |
|
||||
|---|---|---|
|
||||
| Empty list | "No items yet" | "Nothing kept here yet." |
|
||||
| 404 | "Page not found" | "This page is not in the ledger." |
|
||||
| Loading | "Loading..." | "Fetching..." (just keep it plain — the mythic note is reserved for moments with more space) |
|
||||
| Save success | "Saved!" | "Saved." (plain — success doesn't need flavor) |
|
||||
| Save error | "Error saving" | "Couldn't save. The change has been kept locally — try again in a moment." |
|
||||
| Delete confirm | "Delete this?" | "Remove this from the ledger? This can't be undone." |
|
||||
|
||||
The pattern: action-adjacent language stays plain; absence/failure/waiting gets the flavor.
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
### Style
|
||||
|
||||
- Stroke-based, 1.5px stroke weight at 24px, 1px at 16px
|
||||
- Rounded line caps and joins
|
||||
- 24px or 16px grid
|
||||
- Outline style by default; filled style only for active/selected states
|
||||
|
||||
Use **Lucide** (https://lucide.dev) as the base icon set — it matches this style exactly and is open-source. Only commission custom icons for app-specific concepts that Lucide doesn't cover.
|
||||
|
||||
### Don't
|
||||
|
||||
- No filled icons in default UI (reserve for active states)
|
||||
- No icon styles that mix stroke and fill chaotically
|
||||
- No literal medieval imagery (swords, scrolls with curls, banners) in functional UI
|
||||
- No emoji as icons
|
||||
|
||||
---
|
||||
|
||||
## Per-app application
|
||||
|
||||
### Fabled Scribe (#5B4A8A — dusty violet)
|
||||
|
||||
A second-brain notes and task management tool. The accent appears in: the wordmark, hashtags and tag pills, the active nav item, text selection color, and the cursor in the editor. Notes are presented on Iron-surfaced cards with generous reading line-height. The hashtag system uses Scribe's accent for visual continuity.
|
||||
|
||||
### Minstrel (#4A6B5C — forest teal)
|
||||
|
||||
Self-hosted music. The accent appears on: now-playing indicators, active track highlights, the wordmark, equalizer/visualization elements. Album art dominates visually, so the accent should appear in chrome and metadata, never overlapping cover imagery.
|
||||
|
||||
### Fabled Forge (#8B5A2B — forge bronze)
|
||||
|
||||
Story-building and worldbuilding tool. The accent appears on: the wordmark, character/location/object markers in story trees, the editor cursor, "kept/canon" indicators distinguishing finalized story elements from drafts. This app benefits from Fraunces being used more aggressively — for entity titles, chapter headings, etc.
|
||||
|
||||
### Roundtable (#4A5D7E — slate blue)
|
||||
|
||||
Home server management. The accent appears on: the wordmark, healthy/online status indicators, the active dashboard panel border. Status colors here are critical — green for healthy, amber for warning, red (the orange-red error tone, not oxblood) for failed. The accent itself indicates "this is the panel I'm currently looking at."
|
||||
|
||||
**Naming note:** "Roundtable" leans the wrong direction — its connotation is *equal participants in discussion* rather than *one steward managing a domain*. Consider "Steward" or "Castellan" if you revisit naming. Castellan in particular is good — it specifically means "the officer in charge of a castle."
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes
|
||||
|
||||
### CSS custom properties
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--fs-obsidian: #14171A;
|
||||
--fs-iron: #1E2228;
|
||||
--fs-slate: #2C313A;
|
||||
--fs-pewter: #3F4651;
|
||||
|
||||
/* Text */
|
||||
--fs-parchment: #E8E4D8;
|
||||
--fs-vellum: #C2BFB4;
|
||||
--fs-ash: #9C9A92;
|
||||
|
||||
/* Action */
|
||||
--fs-moss: #4A5D3F;
|
||||
--fs-bronze: #8B7355;
|
||||
|
||||
/* Semantic */
|
||||
--fs-warning: #8B6F1E;
|
||||
--fs-error: #C04A1F;
|
||||
--fs-info: #3D5A6E;
|
||||
--fs-oxblood: #6B2118;
|
||||
|
||||
/* Typography */
|
||||
--fs-font-display: 'Fraunces', Georgia, serif;
|
||||
--fs-font-body: 'Inter', system-ui, sans-serif;
|
||||
--fs-font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* Layout */
|
||||
--fs-radius-sm: 4px;
|
||||
--fs-radius-md: 8px;
|
||||
--fs-radius-lg: 12px;
|
||||
--fs-radius-xl: 16px;
|
||||
}
|
||||
|
||||
/* Per-app accent — set ONE of these on the root for each app */
|
||||
[data-app="scribe"] { --fs-accent: #5B4A8A; }
|
||||
[data-app="minstrel"] { --fs-accent: #4A6B5C; }
|
||||
[data-app="forge"] { --fs-accent: #8B5A2B; }
|
||||
[data-app="roundtable"]{ --fs-accent: #4A5D7E; }
|
||||
```
|
||||
|
||||
### Tailwind integration
|
||||
|
||||
If using Tailwind, extend the theme with these tokens rather than relying on default colors. The default Tailwind palette will fight this system — you'll get drift back toward bright defaults if you don't lock down the palette explicitly.
|
||||
|
||||
---
|
||||
|
||||
## What this kit deliberately does NOT include
|
||||
|
||||
- **Logo files.** The lockup design is described conceptually but the actual mark needs to be drawn. Hire a designer or use Claude Design to iterate on a heraldic FS monogram.
|
||||
- **Marketing site design.** This kit is for application UI. The umbrella marketing site (FabledSword.com) can use this system but will need additional patterns (hero layouts, feature grids, etc.).
|
||||
- **Email templates.** Different constraints, different problem.
|
||||
- **Print collateral.** Not in scope.
|
||||
- **Mobile native app patterns.** This is web-first. iOS/Android conventions would override several choices here (button shapes, navigation patterns).
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 25, 2026. Iterate as the family of apps grows.*
|
||||
|
||||
---
|
||||
|
||||
# Scribe-specific decisions in progress
|
||||
|
||||
> This section tracks decisions made while adapting the FabledSword baseline above for Scribe specifically. Items here are *in progress* — once they feel solid, they get folded into the main body of the document (either as Scribe-specific extensions in the per-app section, or as updates to the universal rules where Scribe's needs reveal a gap in the baseline).
|
||||
|
||||
*Iteration started: 2026-04-26. Foundation pass shipped 2026-04-27 in `7a9a8b7` (palette, fonts, light mode, action tokens, hardcoded indigo cleanup, warm-gold deprecation). Surface phase shipped 2026-04-27 across `93a3beb` → `3c1ec40` (Lucide migration, Hybrid-rule button reclassification per surface, long-form line-height, two-weights-only). The system is now applied end-to-end; this section will fold into the main body once the result has had time to settle in real use.*
|
||||
|
||||
## Decisions made so far
|
||||
|
||||
### Accent footprint — Hybrid rule (not Strict)
|
||||
|
||||
The doc baseline says the per-app accent only appears on wordmark, active nav, cursor, and text selection — never on action buttons. Scribe currently uses indigo on essentially every interactive surface (CTAs, scrollbars, glows, borders, focus rings). Hard-cutting to the doc baseline would lose too much identity in one swing.
|
||||
|
||||
**Hybrid rule:** the accent reserves a slightly larger footprint than the doc baseline, but still much smaller than today.
|
||||
|
||||
- **Accent (dusty violet) lives on:** wordmark; active nav; cursor and text selection in editor surfaces; tags/pills/wikilinks; in-progress task badge; focus rings; **brand-moment CTAs** — chat Send, "Create note" empty-state CTA, journal Send, "Start journaling" empty-state.
|
||||
- **Moss (sage-green primary) lives on:** Save / Submit / Confirm in forms and modals; generic affirmative actions where the button just means "do this thing" with no brand pretense.
|
||||
- **Bronze (secondary):** Cancel-but-not-destructive, alternative paths.
|
||||
- **Oxblood (destructive):** Delete / Remove (paired with an icon).
|
||||
- **Pewter ghost:** tertiary actions, "later", "skip", "see also".
|
||||
|
||||
**Rule of thumb:** if the user is engaging with a *Scribe-feature moment* (sending a chat, opening a fresh note, jumping into the journal), accent. If they're just *operating the software* (saving an edit, confirming a dialog), Moss.
|
||||
|
||||
### Light mode — warm parchment, matched aesthetic
|
||||
|
||||
The doc is dark-only. Scribe today supports both light and dark, and we keep both. The light mode is derived to *match* the dark mode aesthetic rather than defaulting to system white-and-ink.
|
||||
|
||||
- Page background: in the `#F5F1E8` warm cream family (specific values TBD)
|
||||
- Cards: near-white but slightly tinted
|
||||
- Text: deep ink `#14171A` (mirroring Obsidian)
|
||||
- Accent: same dusty violet `#5B4A8A` (works on both themes)
|
||||
|
||||
The metaphor stays consistent across themes: ink on aged paper (light) ↔ parchment text on graphite (dark). Light mode is *not* the system standard look.
|
||||
|
||||
**Known downside:** warm parchment backgrounds can fight with embedded color content. Mitigation: code blocks get a slight cool wash in light mode specifically, to keep syntax highlighting readable.
|
||||
|
||||
### Status and priority palette — extend the doc's semantic set
|
||||
|
||||
The doc's semantic colors (Success / Warning / Error / Info / Destructive) are leaner than what Scribe needs for task management. Rather than running a parallel palette, Scribe extends the doc by mapping its status/priority tokens onto doc primitives where they fit and defining new app-level tokens for the rest.
|
||||
|
||||
**Status (task lifecycle):**
|
||||
|
||||
| Token | Color | Source | Logic |
|
||||
|---|---|---|---|
|
||||
| `status-todo` | Pewter `#3F4651` | doc | Neutral, "not started yet" |
|
||||
| `status-in-progress` | dusty violet `#5B4A8A` | accent | Active = brand moment per Hybrid |
|
||||
| `status-done` | Moss `#4A5D3F` | doc Success | Affirmative completion |
|
||||
| `status-cancelled` | Ash `#9C9A92` | doc | Faded, "let go" |
|
||||
| `status-paused` | Warning `#8B6F1E` | doc | Stalled, needs attention — replaces the old warm gold treatment |
|
||||
|
||||
**Priority (loudness scale):**
|
||||
|
||||
| Token | Color | Source | Logic |
|
||||
|---|---|---|---|
|
||||
| `priority-low` | Info `#3D5A6E` | doc | Cool, FYI — quietest end of the spectrum |
|
||||
| `priority-medium` | Warning `#8B6F1E` | doc | Golden, mid-attention |
|
||||
| `priority-high` | Error `#C04A1F` | doc | Terracotta, urgent |
|
||||
| `priority-none` | Vellum/Ash | doc | No signal |
|
||||
|
||||
The priority row reads as a clean cool→warm gradient (slate blue → golden brown → terracotta), which matches the semantic loudness — coherence the current ad-hoc palette doesn't have.
|
||||
|
||||
**Other functional tokens:**
|
||||
|
||||
| Token | Color | Logic |
|
||||
|---|---|---|
|
||||
| `wikilink` | dusty violet | Editorial brand moment per Hybrid |
|
||||
| `overdue` | Error `#C04A1F` | Same as priority-high — overdue IS a priority signal |
|
||||
| `toast-success` | Moss | doc semantic |
|
||||
| `toast-error` | Error | doc semantic |
|
||||
| `toast-info` | Info | doc semantic |
|
||||
| `tag-bg` / `tag-text` | accent at 15% / accent | Per doc pill recipe |
|
||||
|
||||
Each token gets a `*-bg` companion at low alpha (matching the existing pattern in `theme.css`).
|
||||
|
||||
**Removed:** the warm gold accent (`--color-accent-warm: #b8860b`). Its two jobs split:
|
||||
|
||||
- Dates and timestamps (knowledge cards, event details, chat) → use `text-secondary` instead. Dates are metadata, not a brand surface; muted is the correct register.
|
||||
- Paused project status → use the new `status-paused` (Warning `#8B6F1E`) row above. Same golden-brown family, semantically aligned.
|
||||
|
||||
### Typography — adopt the doc's stack and scale
|
||||
|
||||
Adopt the doc's type stack and scale verbatim, with one deferred verification (long-form line-height in practice).
|
||||
|
||||
- **Body font: Inter.** Replaces Scribe's current system-stack body font. Doc-defined; no Scribe-specific divergence.
|
||||
- **Type scale:** as in the doc table — Display 40 / H1 32 / H2 24 / H3 18 / Body 15 / Body small 13 / Label 12 / Code 13 / Tiny 11.
|
||||
- **Two weights only:** 400 regular, 500 medium. No 600/700 (reads heavy in dark mode and against the muted palette).
|
||||
- **Family rules:** Fraunces at 18px+ only (Display, H1, H2). H3 and below = Inter. Code = JetBrains Mono.
|
||||
- **Line height:** 1.5 body, 1.3 headings, **1.7 for long-form reading surfaces** (notes, journal entries).
|
||||
- **Sentence case for everything**, except the Tiny micro-label style which gets uppercase + 0.08em letter-spacing.
|
||||
|
||||
Mechanical rollout — value swaps in `theme.css` plus loading Inter and JetBrains Mono from Google Fonts (Fraunces is already loaded).
|
||||
|
||||
### Light mode — concrete palette
|
||||
|
||||
Fills in the warm-parchment direction picked earlier. Treat these as starting values; tune in practice.
|
||||
|
||||
| Token | Hex | Role |
|
||||
|---|---|---|
|
||||
| Page bg | `#F5F1E8` | Warm cream — the "paper" |
|
||||
| Card bg | `#FBF8F0` | Near-white, slightly warm — raised surfaces |
|
||||
| Inset bg (inputs, code) | `#EFEAE0` | Slightly darker than page, "inset" feeling |
|
||||
| Text primary | `#14171A` | Deep ink — Obsidian inverted |
|
||||
| Text secondary | `#5A5852` | Warm mid-grey |
|
||||
| Text muted | `#9A9890` | Warm light grey for hints/metadata |
|
||||
| Border default | `#D9D6CE` | Warm light pewter, hairline weight |
|
||||
|
||||
**Code-block exception:** in light mode specifically, code blocks use a slight cool wash (e.g. `#EBEDF0`) instead of the warm inset bg, so syntax highlighting reads cleanly. This is the mitigation for the "warm bg fights colored content" downside.
|
||||
|
||||
The accent (`#5B4A8A` dusty violet), Moss, Bronze, Oxblood, and the semantic color set are **identical across themes** — only the surface and text palettes flip.
|
||||
|
||||
### Chat-bubble codification — keep the Illuminated Transcript pattern
|
||||
|
||||
The existing chat-bubble pattern (informally called "Illuminated Transcript") gets written into the design system as a documented chat component. Other apps in the family that add a chat surface inherit the pattern; Scribe's existing implementation continues to work with only color shifts.
|
||||
|
||||
**User bubble (whisper):**
|
||||
- Background: transparent
|
||||
- Border: 0.5px Pewter (was: indigo-tinted)
|
||||
- Text color: secondary (Vellum dark / `#5A5852` light)
|
||||
- Right-aligned, rounded except bottom-right (subtle "from-me" tail)
|
||||
|
||||
**Assistant bubble (lit):**
|
||||
- Background: card surface (Iron dark / `#FBF8F0` light)
|
||||
- Border: none on top/right/bottom; **2px solid accent (dusty violet) on left edge only**
|
||||
- Box-shadow: accent-tinted glow + standard depth shadow (formula: `0 4px 28px rgba(<accent>, 0.14), 0 2px 8px rgba(0,0,0,0.4)` in dark; lower alphas in light)
|
||||
- Text color: primary (Parchment dark / Obsidian-inverted light)
|
||||
- Left-aligned, rounded except bottom-left
|
||||
|
||||
The 2px-accent left edge is the "illumination" — like an illuminated capital in a manuscript. The shadow is the lift. Together they make the assistant bubble read as the *primary* voice, while the user bubble is the *margin note*.
|
||||
|
||||
**Inline tool-call cards (`ToolCallCard`)** rendered inside an assistant bubble do NOT get their own border (per the border philosophy — the bubble already contains them). They use a slight surface tint to differentiate.
|
||||
|
||||
### Iconography — adopt Lucide, enforce a scale
|
||||
|
||||
Scribe currently hand-inlines SVG paths in 16+ Vue files, with 5 different stroke weights and 8+ different sizes. The visual style is already outline + rounded caps + `currentColor` stroke (matches the doc's intent), but there's no shared source and no scale discipline.
|
||||
|
||||
**Migration policy:**
|
||||
|
||||
1. **Install `lucide-vue-next`** as the icon source. Replace hand-inlined SVGs with imported components. Single source of truth.
|
||||
2. **Strict size scale: 16px and 24px only.** Today's mix of 12/13/14/15/17/18/20 collapses to those two. 16 for inline-with-text and small affordances; 24 for nav and primary actions.
|
||||
3. **Stroke weight per the doc: 1.5 at 24px, 1 at 16px.** Lighter than the current default of 2 — reads more refined, matches the muted palette philosophy. Overrides Lucide's default.
|
||||
4. **Outline by default; filled only for active/selected state.** Introduces a new affordance Scribe doesn't currently use — bookmark/pin/star icons can switch outline → filled to indicate active state. Reserve filled style strictly for this.
|
||||
5. **No emoji in chrome.** Replace the 3 files' emoji usage in UI labels/buttons/badges/empty states with Lucide equivalents. Emoji remain fine in *user content* (note bodies, chat messages the user typed).
|
||||
|
||||
Work cost: ~30-60 individual icon swaps across the 16 files. Mechanical; doesn't require redesign of any component.
|
||||
|
||||
### Voice and tone — adopt principles, defer formal audit
|
||||
|
||||
The doc's voice register applies to Scribe (understated mythic — plain for functional UI, flavored for empty/error/loading states). No formal sweep of every UI string yet.
|
||||
|
||||
**Approach:** apply the voice opportunistically as components are touched in the polish pass — when redesigning a settings tab, an empty state, or an error toast, rewrite the copy at the same time using the doc's register and examples table as the guide. A standalone audit pass is deferred unless drift becomes visible.
|
||||
|
||||
### Border philosophy — structural, not decorative
|
||||
|
||||
The doc treats borders as *structural* (Pewter neutral hairlines that say "boundary"), not decorative (Scribe today uses indigo-tinted borders that say "branded edge"). That principle suggests removing borders in places where surface tint and spacing already communicate separation.
|
||||
|
||||
**Borders to remove:**
|
||||
|
||||
- List rows (NotesListView, TasksListView, conversation history) — surface contrast + spacing should separate rows; current border reads as "boxed-in"
|
||||
- Inline `ToolCallCard` inside chat bubbles — the bubble is already a container; an extra border feels like double-wrapping
|
||||
- Filter chips and search-bar pills with a background tint — background does the work
|
||||
- Empty-state callouts with dashed/bordered "nothing here yet" boxes — tinted background reads cleaner
|
||||
|
||||
**Borders to keep (genuinely structural):**
|
||||
|
||||
- Standalone card containers (Notes viewer, Task viewer, the new daily prep card)
|
||||
- Modal / dialog edges
|
||||
- Code blocks (separates content type, not just space)
|
||||
- Focus rings (accessibility)
|
||||
- Major section dividers within a panel
|
||||
|
||||
Border weight is not load-bearing for Scribe — happy to use the doc's 0.5px hairline default; the *placement* discipline matters more than the weight.
|
||||
|
||||
## Open threads (next iterations)
|
||||
|
||||
### Foundation pass — shipped 2026-04-27 (`7a9a8b7`)
|
||||
|
||||
Mechanical token + font + light-mode rewrite of `frontend/src/assets/theme.css`, plus a sweep of hardcoded indigo and `--color-accent-warm` references across ~14 component files. Action tokens (`--color-action-primary` Moss, `--color-action-secondary` Bronze, `--color-action-destructive` Oxblood, `--color-action-ghost-border` Pewter) are defined but not yet applied — buttons still flow through `--color-primary` and read as dusty-violet gradients in the meantime, by design. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-foundation-design.md` (gitignored, local-only).
|
||||
|
||||
### Surface phase — shipped 2026-04-27 (`93a3beb` → `3c1ec40`)
|
||||
|
||||
Bundled as Hybrid (option C from the brainstorm): Lucide cross-cutting first, then surface-by-surface for the judgment work. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-surface-design.md` (gitignored, local-only). Seven PRs landed on `dev`:
|
||||
|
||||
| PR | Commit | Surface | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | `93a3beb` | Lucide cross-cutting | 60 hand-inlined SVGs across 15 files → `lucide-vue-next`. Every chrome icon at 16 or 24. Emoji-as-icons (`✕`, `✓`, `🎤`, `📎`, `↻`, `↑`, `×`, `☀`/`☾`) swept across the chrome. AppLogo wordmark and the GraphView D3 mount kept as the legitimate exceptions. |
|
||||
| 2 | `3d916d7` | Journal | Buttons audited; `↻` weather refresh → Lucide `RotateCcw`; assistant bubble line-height bumped; redundant `.journal-title` font-family dropped; dead `.news-section` CSS removed. |
|
||||
| 3 | `4192a64` | Chat | `.message-content` long-form 1.7 line-height on assistant bubbles; ToolCallCard outer border removed (bubble already contains it; error state moved to a left-edge accent); bulk-delete recolored to Oxblood with Trash2 icon; `.bulk-link` accent → muted text. |
|
||||
| 4 | `efb3534` | Knowledge cluster | `.prose` line-height bumped to 1.7 globally so all reading surfaces inherit. Save → Moss; Delete → Oxblood + Trash2; Edit / Advance → Moss; Convert / Share → Bronze. |
|
||||
| 5 | `ff498ce` | Project + Workspace | Project save panel → Moss; milestone confirm/cancel → Moss/Bronze; modal-btn-danger and per-row delete affordances → Oxblood; "Open Workspace" stays accent (project-surface brand moment). |
|
||||
| 6 | `541e2ed` | Settings | Densest button surface — every `.btn-save`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-danger-outline`, `.btn-toggle-open` reclassified per Hybrid; missing `.btn-danger` style block added (was unstyled before). |
|
||||
| 7 | `3c1ec40` | Edge surfaces | Calendar `New Event` → Moss; EventSlideOver Save/Cancel/Delete reclassified; HomeView hero CTA stays accent (brand moment); two-weights-only sweep across Header/Home/Calendar/Graph. |
|
||||
|
||||
**Cross-cutting changes folded in as the work touched files:**
|
||||
|
||||
- Long-form 1.7 line-height on `.prose` (PR 4) — applies to Note viewer, Task viewer, chat assistant bubbles, anywhere markdown renders into a reading surface.
|
||||
- Two-weights-only (400 + 500) — every `font-weight: 600` and `700` snapped to `500` across all surface PRs.
|
||||
- Hardcoded `--color-danger` in destructive button contexts → `--color-action-destructive` (Oxblood). `--color-danger` (Error terracotta) preserved for validation/error messages, per the doc's distinction between Error and Destructive.
|
||||
- Adjacent `×` / `✕` / unicode-arrow emoji swept opportunistically as files were touched (PRs 5, 7).
|
||||
|
||||
### Out of scope — deferred indefinitely
|
||||
|
||||
Items deliberately not addressed in this round; revisit when a real need surfaces:
|
||||
|
||||
- Lucide stroke-weight overrides (doc spec: 1.5 at 24, 1 at 16; current: Lucide default 2). Touched components if they read too heavy in practice.
|
||||
- Filled-as-active icon state — no current affordance uses it; introduce when bookmark/pin/star toggles are added.
|
||||
- Type-scale / spacing-scale CSS variables — components keep literal values.
|
||||
- Token rename to `--fs-*` namespace — Scribe is the only FabledSword app sharing this codebase.
|
||||
- FabledSword lockup placement — waiting on the actual heraldic mark to be drawn.
|
||||
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
||||
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
||||
|
||||
### Flutter app port — shipped 2026-04-28
|
||||
|
||||
The companion mobile app (`fabled_app` / FabledApp repo) tracks the same design system. Two commits:
|
||||
|
||||
- **Foundation port** — `0f05f47`. `lib/core/theme.dart` rewritten with the Obsidian/Iron/Pewter dark palette, warm parchment light palette, dusty violet `#5B4A8A` primary. Inter loaded for body, JetBrains Mono available at call sites, Fraunces for headlines ≥18px. New `ActionColors` ThemeExtension exposes Moss/Bronze/Oxblood/Pewter outside the `ColorScheme` (Material's primary/secondary/tertiary slots all carry brand accent, so action tokens need their own home). `GradientButton` recolored to dusty-violet gradient.
|
||||
- **Surface phase** — `b9e68e3`. `lucide_icons ^0.257.0` installed; 107 `Icons.*` references across 21 files swapped to `LucideIcons.*`. Input border radius 24 → 8 in both themes. ChatMessageBubble Illuminated Transcript fixes — neutral border on user bubbles, `surface`/Iron bg on assistant bubbles, asymmetric corner restoration (only bottom-left clipped, not both left corners), accent-tinted glow shadow added. 5 destructive confirm buttons across notes / tasks / chat / calendar wired to `ActionColors.destructive`. Calendar event Save wired to `ActionColors.primary` as the reference Moss site. 4 hardcoded indigo Color literals → dusty-violet equivalents.
|
||||
|
||||
The Flutter port doesn't decompose into 7 PRs the way web did because Flutter's centralized `theme.dart` means most palette/font work happens in one file. Per-screen Save / Cancel reclassification beyond the calendar event Save is opportunistic — the wiring pattern (`Theme.of(context).extension<ActionColors>()!.primary`) is established and applied incrementally as files are touched.
|
||||
|
||||
Pattern reference for downstream screens: see `lib/screens/calendar/event_form_sheet.dart` for `ActionColors.primary` usage on Save buttons; see the dialog spots in `note_edit_screen.dart` / `task_edit_screen.dart` / `note_detail_screen.dart` / `conversations_tab_screen.dart` for `ActionColors.destructive` on confirm-Delete buttons.
|
||||
|
||||
### Open threads
|
||||
|
||||
*New threads will accumulate here as gaps surface in real use.*
|
||||
+13
-10
@@ -52,23 +52,26 @@ Full conversation history with SSE streaming. Features:
|
||||
- **Bulk delete** — Select and delete multiple conversations.
|
||||
- **Retention** — Conversations auto-pruned after configurable days (default 90).
|
||||
|
||||
## Daily Briefing
|
||||
## Daily Journal
|
||||
|
||||
`/briefing` is a scheduled, dialogue-based morning briefing. The assistant compiles tasks, calendar events, projects, weather forecast, and RSS digest at configurable times, then checks in throughout the day. You can reply interactively.
|
||||
`/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.
|
||||
|
||||
**Schedule** — Configurable slots: morning (default 4am compile), midday (8am check-in), evening (12pm check-in), night (4pm). Scheduler catches up missed slots on startup.
|
||||
**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.
|
||||
|
||||
**Configuration** — Settings → Briefing: enable toggle, location geocoding, office days, time slot toggles, RSS feed management, push notification toggle.
|
||||
**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.
|
||||
|
||||
**RSS feeds** — Add feed URLs with optional name and category. Feeds are fetched and cached; the briefing digest includes recent items. Category badges shown in the UI. Feeds can be manually refreshed.
|
||||
**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. Multiple locations supported (home, work, or any city name). Geocoding via Nominatim.
|
||||
**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.
|
||||
|
||||
**Profile note** — The assistant maintains a profile note for each user that it updates based on briefing conversations, improving personalisation over time.
|
||||
**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 notes. A lightweight `search_web` tool answers quick questions inline without saving. Requires `SEARXNG_URL` to be configured.
|
||||
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
|
||||
|
||||
@@ -115,10 +118,10 @@ Settings are tabbed:
|
||||
|-----|----------|
|
||||
| General | Assistant name, default model, model management (pull/delete) |
|
||||
| Account | Email change, password change, session invalidation |
|
||||
| Notifications | Push notification subscription, briefing push toggle |
|
||||
| Notifications | Push notification subscription, journal prep push toggle |
|
||||
| Profile | About you, response preferences, interests, work schedule, locations + temperature unit, journal prep schedule, learned observations |
|
||||
| Integrations | CalDAV configuration, SearXNG status |
|
||||
| Data | Personal export, backup/restore (admin) |
|
||||
| Briefing | Enable, location, office days, slots, RSS feeds, weather |
|
||||
| API Keys | Create/revoke API keys, Fable MCP download and install |
|
||||
| Config (admin) | Base URL, SMTP, OIDC settings |
|
||||
| Users (admin) | User list, invite links, registration toggle |
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,786 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,785 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,782 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,144 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,204 @@
|
||||
# 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
|
||||
@@ -0,0 +1,249 @@
|
||||
# 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
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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
|
||||
+147
-33
@@ -1,16 +1,16 @@
|
||||
"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport."""
|
||||
"""Fable MCP server — exposes Fable Scribe as MCP tools via stdio transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, briefing
|
||||
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, journal
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_INSTRUCTIONS = """
|
||||
Fable Assistant is a self-hosted second-brain and project management system with LLM integration.
|
||||
Fabled Scribe is a self-hosted second-brain and project management system with LLM integration.
|
||||
|
||||
## Data model
|
||||
|
||||
@@ -27,7 +27,7 @@ The hierarchy is: Project → Milestone → Task/Note.
|
||||
|
||||
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
|
||||
- Status values: `todo`, `in_progress`, `done`, `cancelled`
|
||||
- Priority values: `low`, `normal`, `high`
|
||||
- Priority values: `none` (default), `low`, `medium`, `high`
|
||||
|
||||
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
|
||||
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
|
||||
@@ -68,10 +68,19 @@ Use the direct CRUD tools when:
|
||||
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
|
||||
its main body. Suitable for recording work sessions, decisions, or status updates over time.
|
||||
|
||||
## RSS / Briefing
|
||||
## Journal
|
||||
|
||||
Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items.
|
||||
Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing.
|
||||
Fable Scribe runs a per-day Journal — a conversational surface where the user narrates
|
||||
their day. Each day has its own conversation. The first assistant message in a day's
|
||||
conversation is the **daily prep**: an LLM-generated briefing covering today's tasks,
|
||||
calendar events, weather, active projects, and recent journal context. Subsequent turns
|
||||
are user/assistant journaling exchanges; the LLM may emit **Moments** (small structured
|
||||
extractions) via the `record_moment` tool during the conversation.
|
||||
|
||||
Use `fable_get_today_journal` to inspect today's prep + conversation. Use
|
||||
`fable_get_journal_day` for past days. Use `fable_list_moments` to query the structured
|
||||
journal extractions across days. Use `fable_trigger_journal_prep` to force-regenerate
|
||||
today's prep prose.
|
||||
|
||||
## Admin logs
|
||||
|
||||
@@ -241,7 +250,7 @@ async def fable_create_task(
|
||||
title: Task title (required).
|
||||
body: Markdown description / notes for the task.
|
||||
status: Initial status — one of: todo (default), in_progress, done, cancelled.
|
||||
priority: One of: low, normal, high. Omit for no priority.
|
||||
priority: One of: low, medium, high. Omit for no priority (defaults to "none").
|
||||
project_id: Associate with a project (0 = no project).
|
||||
milestone_id: Place within a project milestone (0 = no milestone).
|
||||
parent_id: Make this a sub-task of another task (0 = top-level).
|
||||
@@ -280,7 +289,7 @@ async def fable_update_task(
|
||||
title: New title, or omit to leave unchanged.
|
||||
body: New markdown body, or omit to leave unchanged.
|
||||
status: New status — one of: todo, in_progress, done, cancelled.
|
||||
priority: New priority — one of: low, normal, high.
|
||||
priority: New priority — one of: none, low, medium, high.
|
||||
project_id: New project (0 = remove from project). Omit to leave unchanged.
|
||||
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
|
||||
"""
|
||||
@@ -582,50 +591,155 @@ async def fable_get_app_logs(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Briefing / RSS
|
||||
# Journal — daily prep, day payloads, moments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_rss_feeds() -> dict:
|
||||
"""List all RSS/Atom feeds configured in Fable for the current user.
|
||||
async def fable_get_today_journal() -> dict:
|
||||
"""Fetch today's Journal day payload.
|
||||
|
||||
Returns id, title, url, category, and last_fetched_at for each feed.
|
||||
These feeds are summarised in the user's daily briefing.
|
||||
Creates today's journal conversation and generates the daily prep
|
||||
message if neither exists yet. Returns:
|
||||
{
|
||||
"day_date": "YYYY-MM-DD",
|
||||
"conversation": { id, title, conversation_type, day_date, ... },
|
||||
"messages": [ ... ordered list of messages ... ]
|
||||
}
|
||||
|
||||
The first assistant message is the daily prep — a conversational
|
||||
opener generated by the LLM from gathered tasks/events/weather/
|
||||
projects/recent moments/open threads. Its ``msg_metadata.sections``
|
||||
carries the underlying structured data for inspection.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.list_rss_feeds(client)
|
||||
return await journal.get_today_journal(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_add_rss_feed(
|
||||
url: str,
|
||||
title: str = "",
|
||||
category: str = "",
|
||||
) -> dict:
|
||||
"""Add an RSS or Atom feed to Fable's daily briefing.
|
||||
async def fable_get_journal_day(iso_date: str) -> dict:
|
||||
"""Fetch a specific day's Journal payload by ISO date.
|
||||
|
||||
Args:
|
||||
url: The RSS/Atom feed URL (required).
|
||||
title: Optional display name. If omitted, auto-populated from feed metadata.
|
||||
category: Optional category label to group feeds (e.g. "news", "tech", "finance").
|
||||
iso_date: YYYY-MM-DD format date.
|
||||
|
||||
Returns the created feed object including its assigned id.
|
||||
Returns the same shape as fable_get_today_journal. If no journal
|
||||
exists for that day, ``conversation`` and ``messages`` will be
|
||||
null/empty respectively.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.add_rss_feed(
|
||||
client,
|
||||
url=url,
|
||||
title=title or None,
|
||||
category=category or None,
|
||||
return await journal.get_journal_day(client, iso_date=iso_date)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_journal_days() -> dict:
|
||||
"""List dates that have journal content for the current user, newest first.
|
||||
|
||||
Returns ``{"days": ["YYYY-MM-DD", ...]}``. Use these dates to query
|
||||
specific days via ``fable_get_journal_day``.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await journal.list_journal_days(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_trigger_journal_prep(iso_date: str = "") -> dict:
|
||||
"""Force-regenerate the daily prep prose for today (or a specific day).
|
||||
|
||||
The prep is the first assistant message in a day's journal — a
|
||||
conversational LLM-generated briefing built from tasks/events/weather/
|
||||
projects/recent moments/open threads. Use this to iterate on the prep
|
||||
prompt or refresh after data changes.
|
||||
|
||||
Args:
|
||||
iso_date: Optional YYYY-MM-DD. If empty, regenerates today.
|
||||
|
||||
Returns ``{"ok": true, "message_id": ...}``.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await journal.trigger_journal_prep(
|
||||
client, iso_date=iso_date or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_remove_rss_feed(feed_id: int) -> dict:
|
||||
"""Remove an RSS feed from Fable by its ID."""
|
||||
async def fable_get_journal_config() -> dict:
|
||||
"""Fetch the user's journal config.
|
||||
|
||||
Includes prep schedule (prep_enabled / prep_hour / prep_minute),
|
||||
day rollover hour, phase boundaries, and any locations / temp_unit
|
||||
used for the prep's weather section.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.remove_rss_feed(client, feed_id=feed_id)
|
||||
return await journal.get_journal_config(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_moments(
|
||||
query: str = "",
|
||||
person_id: int = 0,
|
||||
place_id: int = 0,
|
||||
tag: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
pinned_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""Search/list journal Moments — small structured extractions the LLM emits during journaling.
|
||||
|
||||
All filters are optional and combinable. Without a query, returns
|
||||
moments ordered by occurred_at DESC. With a query, returns
|
||||
semantically-ranked moments above the similarity threshold.
|
||||
|
||||
Args:
|
||||
query: Optional semantic query string.
|
||||
person_id: Filter to moments mentioning this person (0 = no filter).
|
||||
place_id: Filter to moments mentioning this place (0 = no filter).
|
||||
tag: Filter to moments with this tag.
|
||||
date_from: ISO YYYY-MM-DD lower bound (inclusive).
|
||||
date_to: ISO YYYY-MM-DD upper bound (inclusive).
|
||||
pinned_only: If True, only return pinned moments.
|
||||
limit: Max results, default 50.
|
||||
|
||||
Returns ``{"moments": [...]}`` where each moment has id, day_date,
|
||||
occurred_at, content, raw_excerpt, tags, people, places, task_ids,
|
||||
note_ids, pinned, and (when query set) score.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await journal.list_moments(
|
||||
client,
|
||||
query=query or None,
|
||||
person_id=person_id if person_id else None,
|
||||
place_id=place_id if place_id else None,
|
||||
tag=tag or None,
|
||||
date_from=date_from or None,
|
||||
date_to=date_to or None,
|
||||
pinned_only=pinned_only,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic conversation access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_conversation(conversation_id: int) -> dict:
|
||||
"""Fetch any conversation (chat or journal) with its full message list.
|
||||
|
||||
Returns conversation metadata plus an ordered ``messages`` array.
|
||||
Each message includes role, content, tool_calls (with results),
|
||||
context_note_id, and msg_metadata. Tool calls are in the stored
|
||||
flat format: ``[{"function": name, "arguments": {...}, "result": {...}}]``.
|
||||
|
||||
Useful for inspecting journal preps, chat history, or verifying
|
||||
that a tool actually ran with the expected arguments.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await journal.get_conversation(
|
||||
client, conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""MCP tools for Fable RSS feed management."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_rss_feeds(client: FableClient) -> dict[str, Any]:
|
||||
"""List the user's RSS feeds."""
|
||||
return await client.get("/api/briefing/feeds")
|
||||
|
||||
|
||||
async def add_rss_feed(
|
||||
client: FableClient,
|
||||
*,
|
||||
url: str,
|
||||
title: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a new RSS feed. Title is optional — auto-populated from feed metadata."""
|
||||
payload: dict[str, Any] = {"url": url}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
if category:
|
||||
payload["category"] = category
|
||||
return await client.post("/api/briefing/feeds", json=payload)
|
||||
|
||||
|
||||
async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]:
|
||||
"""Remove an RSS feed by ID."""
|
||||
return await client.delete(f"/api/briefing/feeds/{feed_id}")
|
||||
@@ -0,0 +1,96 @@
|
||||
"""MCP tools for inspecting and controlling the Fable Scribe Journal."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
# ── Day payloads ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_today_journal(client: FableClient) -> dict[str, Any]:
|
||||
"""Fetch today's journal day payload (creates today's conversation + prep if needed)."""
|
||||
return await client.get("/api/journal/today")
|
||||
|
||||
|
||||
async def get_journal_day(client: FableClient, *, iso_date: str) -> dict[str, Any]:
|
||||
"""Fetch a specific day's journal payload by ISO date (YYYY-MM-DD)."""
|
||||
return await client.get(f"/api/journal/day/{iso_date}")
|
||||
|
||||
|
||||
async def list_journal_days(client: FableClient) -> dict[str, Any]:
|
||||
"""List dates that have journal content for the current user."""
|
||||
return await client.get("/api/journal/days")
|
||||
|
||||
|
||||
# ── Daily prep ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def trigger_journal_prep(
|
||||
client: FableClient,
|
||||
*,
|
||||
iso_date: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Force-regenerate the daily prep for today (or a specific day)."""
|
||||
payload: dict[str, Any] = {}
|
||||
if iso_date:
|
||||
payload["date"] = iso_date
|
||||
return await client.post("/api/journal/trigger-prep", json=payload)
|
||||
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_journal_config(client: FableClient) -> dict[str, Any]:
|
||||
"""Fetch the user's journal config (prep schedule, day rollover, locations, etc.)."""
|
||||
return await client.get("/api/journal/config")
|
||||
|
||||
|
||||
# ── Moments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def list_moments(
|
||||
client: FableClient,
|
||||
*,
|
||||
query: str | None = None,
|
||||
person_id: int | None = None,
|
||||
place_id: int | None = None,
|
||||
tag: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
pinned_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""List/search journal moments with optional filters.
|
||||
|
||||
All params are optional. Returns a dict with a ``moments`` array.
|
||||
"""
|
||||
params: dict[str, str] = {"limit": str(limit)}
|
||||
if query:
|
||||
params["query"] = query
|
||||
if person_id is not None:
|
||||
params["person_id"] = str(person_id)
|
||||
if place_id is not None:
|
||||
params["place_id"] = str(place_id)
|
||||
if tag:
|
||||
params["tag"] = tag
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
if pinned_only:
|
||||
params["pinned_only"] = "true"
|
||||
return await client.get("/api/journal/moments", params=params)
|
||||
|
||||
|
||||
# ── Generic conversation access (still works for journal conversations) ───────
|
||||
|
||||
|
||||
async def get_conversation(
|
||||
client: FableClient,
|
||||
*,
|
||||
conversation_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch any conversation (chat or journal) with its full message list."""
|
||||
return await client.get(f"/api/chat/conversations/{conversation_id}")
|
||||
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.4"
|
||||
description = "MCP server for Fabled Assistant"
|
||||
version = "0.3.0"
|
||||
description = "MCP server for Fabled Scribe"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.0",
|
||||
|
||||
Generated
+771
@@ -0,0 +1,771 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp", extra = ["cli"] },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "respx" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0" },
|
||||
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.27.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cli = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "respx"
|
||||
version = "0.23.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.24.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.44.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
|
||||
]
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Fabled">
|
||||
<title>Fabled Assistant</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Scribe">
|
||||
<title>Fabled Scribe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+403
-479
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"@ricky0123/vad-web": "^0.0.30",
|
||||
"@tiptap/core": "^3.0.0",
|
||||
"@tiptap/extension-link": "^3.0.0",
|
||||
"@tiptap/extension-list": "^3.0.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"@tiptap/vue-3": "^3.0.0",
|
||||
"d3": "^7",
|
||||
"dompurify": "^3.1.0",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"marked": "^17.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "3.5.30",
|
||||
@@ -35,6 +37,7 @@
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-static-copy": "^4.0.1",
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Fabled Assistant",
|
||||
"short_name": "Fabled",
|
||||
"name": "Fabled Scribe",
|
||||
"short_name": "Scribe",
|
||||
"description": "Your self-hosted second brain with AI assistance",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
|
||||
@@ -12,7 +12,7 @@ self.addEventListener('push', event => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return self.registration.showNotification(data.title || 'Fabled Assistant', {
|
||||
return self.registration.showNotification(data.title || 'Scribe', {
|
||||
body: data.body || '',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
|
||||
@@ -5,6 +5,7 @@ import AppHeader from "@/components/AppHeader.vue";
|
||||
import ToastNotification from "@/components/ToastNotification.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useOnnxPreloader } from "@/composables/useOnnxPreloader";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
@@ -12,6 +13,9 @@ import { apiGet, apiPut } from "@/api/client";
|
||||
|
||||
useTheme();
|
||||
|
||||
const { schedulePreload: scheduleVadPreload } = useOnnxPreloader();
|
||||
scheduleVadPreload();
|
||||
|
||||
const router = useRouter();
|
||||
const appVersion = ref("dev");
|
||||
const authStore = useAuthStore();
|
||||
@@ -90,7 +94,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case "h": router.push("/"); break;
|
||||
case "n": router.push("/notes"); break;
|
||||
case "t": router.push("/tasks"); break;
|
||||
case "t": router.push("/"); break;
|
||||
case "p": router.push("/projects"); break;
|
||||
case "c": router.push("/chat"); break;
|
||||
case "g": router.push("/graph"); break;
|
||||
@@ -202,7 +206,7 @@ onUnmounted(() => {
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">t</kbd>
|
||||
<span class="shortcut-desc">Tasks</span>
|
||||
<span class="shortcut-desc">Knowledge (tasks)</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
|
||||
+71
-86
@@ -300,139 +300,124 @@ export function apiSSEStream(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Briefing
|
||||
// Journal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BriefingLocation {
|
||||
export interface JournalLocation {
|
||||
label: string;
|
||||
address: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface BriefingSlots {
|
||||
compilation: boolean;
|
||||
morning: boolean;
|
||||
midday: boolean;
|
||||
afternoon: boolean;
|
||||
export interface JournalConfig {
|
||||
prep_enabled: boolean;
|
||||
prep_hour: number;
|
||||
prep_minute: number;
|
||||
day_rollover_hour: number;
|
||||
morning_end_hour?: number;
|
||||
midday_end_hour?: number;
|
||||
// Ambient-context fields (carried forward from the briefing config schema)
|
||||
locations?: { home?: JournalLocation; work?: JournalLocation };
|
||||
temp_unit?: 'C' | 'F';
|
||||
use_caldav_event_locations?: boolean;
|
||||
enabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BriefingConfig {
|
||||
enabled: boolean;
|
||||
locations: {
|
||||
home?: BriefingLocation;
|
||||
work?: BriefingLocation;
|
||||
};
|
||||
use_caldav_event_locations: boolean;
|
||||
work_days: number[];
|
||||
slots: BriefingSlots;
|
||||
notifications: boolean;
|
||||
temp_unit: 'C' | 'F';
|
||||
}
|
||||
|
||||
export interface BriefingFeed {
|
||||
export interface JournalConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string | null;
|
||||
last_fetched_at: string | null;
|
||||
}
|
||||
|
||||
export interface BriefingConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
briefing_date: string | null;
|
||||
model: string;
|
||||
conversation_type: string;
|
||||
day_date: string | null;
|
||||
rag_project_id: number | null;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BriefingMessage {
|
||||
export interface JournalMessage {
|
||||
id: number;
|
||||
conversation_id: number;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status: string;
|
||||
context_note_id: number | null;
|
||||
tool_calls: unknown[] | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
||||
enabled: false,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
};
|
||||
|
||||
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
||||
try {
|
||||
const data = await apiGet<BriefingConfig>('/api/briefing/config');
|
||||
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
|
||||
} catch {
|
||||
return { ...DEFAULT_BRIEFING_CONFIG };
|
||||
}
|
||||
export interface JournalDayPayload {
|
||||
day_date: string;
|
||||
conversation: JournalConversation | null;
|
||||
messages: JournalMessage[];
|
||||
}
|
||||
|
||||
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
|
||||
await apiPut('/api/briefing/config', config);
|
||||
export interface JournalMoment {
|
||||
id: number;
|
||||
user_id: number;
|
||||
conversation_id: number | null;
|
||||
source_message_id: number | null;
|
||||
day_date: string;
|
||||
occurred_at: string;
|
||||
recorded_at: string;
|
||||
content: string;
|
||||
raw_excerpt: string | null;
|
||||
tags: string[];
|
||||
pinned: boolean;
|
||||
people: { id: number; title: string }[];
|
||||
places: { id: number; title: string }[];
|
||||
task_ids: number[];
|
||||
note_ids: number[];
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
|
||||
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
|
||||
return data;
|
||||
export async function getJournalConfig(): Promise<JournalConfig> {
|
||||
return apiGet<JournalConfig>('/api/journal/config');
|
||||
}
|
||||
|
||||
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
|
||||
const body: Record<string, string> = { url };
|
||||
if (category?.trim()) body.category = category.trim();
|
||||
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body);
|
||||
return { ...data, last_fetched_at: null };
|
||||
export async function saveJournalConfig(config: JournalConfig): Promise<void> {
|
||||
await apiPut('/api/journal/config', config);
|
||||
}
|
||||
|
||||
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
|
||||
return apiPost('/api/briefing/feeds/refresh', {});
|
||||
export async function getJournalToday(): Promise<JournalDayPayload> {
|
||||
return apiGet<JournalDayPayload>('/api/journal/today');
|
||||
}
|
||||
|
||||
export async function deleteBriefingFeed(id: number): Promise<void> {
|
||||
await apiDelete(`/api/briefing/feeds/${id}`);
|
||||
export async function getJournalDay(isoDate: string): Promise<JournalDayPayload> {
|
||||
return apiGet<JournalDayPayload>(`/api/journal/day/${isoDate}`);
|
||||
}
|
||||
|
||||
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
|
||||
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
|
||||
return data.conversations;
|
||||
export async function getJournalDays(): Promise<string[]> {
|
||||
const data = await apiGet<{ days: string[] }>('/api/journal/days');
|
||||
return data.days;
|
||||
}
|
||||
|
||||
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
|
||||
return apiGet('/api/briefing/conversations/today');
|
||||
export async function triggerJournalPrep(date?: string): Promise<{ ok: boolean; message_id: number }> {
|
||||
return apiPost('/api/journal/trigger-prep', date ? { date } : {});
|
||||
}
|
||||
|
||||
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
|
||||
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
|
||||
return data.messages;
|
||||
export async function listJournalMoments(params: Record<string, string | number | boolean> = {}): Promise<JournalMoment[]> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
|
||||
const data = await apiGet<{ moments: JournalMoment[] }>(`/api/journal/moments?${qs}`);
|
||||
return data.moments;
|
||||
}
|
||||
|
||||
export async function triggerBriefingSlot(slot: string): Promise<void> {
|
||||
await apiPost('/api/briefing/trigger', { slot });
|
||||
export async function updateJournalMoment(id: number, patch: Partial<JournalMoment>): Promise<JournalMoment> {
|
||||
return apiPatch<JournalMoment>(`/api/journal/moments/${id}`, patch);
|
||||
}
|
||||
|
||||
export async function postRssReaction(
|
||||
rssItemId: number,
|
||||
reaction: 'up' | 'down'
|
||||
): Promise<{ ok: boolean; action: string }> {
|
||||
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
|
||||
}
|
||||
|
||||
export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
||||
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
||||
}
|
||||
|
||||
export async function openArticleInChat(itemId: number): Promise<{ conversation_id: number }> {
|
||||
return apiPost(`/api/chat/from-article/${itemId}`, {});
|
||||
export async function deleteJournalMoment(id: number): Promise<void> {
|
||||
await apiDelete(`/api/journal/moments/${id}`);
|
||||
}
|
||||
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||
try {
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/journal/weather/geocode', { query: address });
|
||||
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -50,34 +50,42 @@
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
|
||||
the software" — not a brand moment. Accent gradient is reserved for
|
||||
Send / empty-state CTAs. */
|
||||
.btn-save {
|
||||
padding: 0.45rem 1.1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
opacity: 0.95;
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-save:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
|
||||
with a Trash icon at the call site to reinforce intent. */
|
||||
.btn-delete {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-danger);
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-delete:hover { background: var(--color-action-destructive-hover); }
|
||||
.btn-assist-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.4rem 0.9rem;
|
||||
@@ -99,7 +107,7 @@
|
||||
border-bottom: 1.5px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
@@ -112,7 +120,6 @@
|
||||
}
|
||||
.title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
.editor-tabs {
|
||||
@@ -225,7 +232,7 @@
|
||||
.assist-panel-title {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -269,7 +276,7 @@
|
||||
/* Section list */
|
||||
.assist-sections-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -362,7 +369,6 @@
|
||||
.assist-streaming-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -391,7 +397,6 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -411,7 +416,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-toggle-view {
|
||||
@@ -454,7 +459,7 @@
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
}
|
||||
.diff-text {
|
||||
flex: 1;
|
||||
@@ -464,7 +469,6 @@
|
||||
.diff-empty {
|
||||
padding: 0.5rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.assist-actions {
|
||||
@@ -562,7 +566,7 @@
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
@@ -581,7 +585,7 @@
|
||||
}
|
||||
.sb-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
/* Long-form reading line-height per the design system —
|
||||
reading surfaces (notes, tasks, journal, assistant bubbles) want 1.7
|
||||
so prose breathes like a book, not a UI snippet. */
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
|
||||
+152
-92
@@ -1,106 +1,147 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
|
||||
|
||||
: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: #6366f1;
|
||||
--color-danger: #d93025;
|
||||
--color-tag-bg: #ede9fe;
|
||||
--color-tag-text: #4f46e5;
|
||||
/* Light mode — warm parchment palette */
|
||||
--color-bg: #F5F1E8;
|
||||
--color-bg-secondary: #FBF8F0;
|
||||
--color-bg-card: #FBF8F0;
|
||||
--color-surface: #EFEAE0;
|
||||
--color-text: #14171A;
|
||||
--color-text-secondary: #5A5852;
|
||||
--color-text-muted: #9A9890;
|
||||
--color-border: #D9D6CE;
|
||||
--color-input-border: #D9D6CE;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--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: #6366f1;
|
||||
--color-status-in-progress-bg: #ede9fe;
|
||||
--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-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.12);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #EBEDF0;
|
||||
--color-code-inline-bg: #EBEDF0;
|
||||
--color-table-stripe: rgba(20, 23, 26, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #EFEAE0;
|
||||
--color-input-bar-text: #14171A;
|
||||
--color-input-bar-placeholder: rgba(20, 23, 26, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
|
||||
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
|
||||
--color-bubble-user-text: #3a3a4a;
|
||||
--color-bubble-asst-shadow: 0 2px 16px rgba(99, 102, 241, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #D9D6CE;
|
||||
--color-bubble-user-text: #5A5852;
|
||||
--color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.08);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.12);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.20);
|
||||
|
||||
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
--chat-reading-width: min(1200px, 100%);
|
||||
--chat-context-sidebar-width: 220px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #111113;
|
||||
--color-bg-secondary: #18181f;
|
||||
--color-bg-card: #1e1e27;
|
||||
--color-surface: #1a1b22;
|
||||
--color-text: #e4e4f0;
|
||||
--color-text-secondary: #8888a8;
|
||||
--color-text-muted: #52526a;
|
||||
--color-border: rgba(99, 102, 241, 0.10);
|
||||
--color-input-border: rgba(99, 102, 241, 0.22);
|
||||
--color-primary: #818cf8;
|
||||
--color-danger: #f44336;
|
||||
--color-tag-bg: #2a2a45;
|
||||
--color-tag-text: #a5b4fc;
|
||||
/* Dark mode — Obsidian / Iron / Pewter */
|
||||
--color-bg: #14171A;
|
||||
--color-bg-secondary: #1E2228;
|
||||
--color-bg-card: #1E2228;
|
||||
--color-surface: #2C313A;
|
||||
--color-text: #E8E4D8;
|
||||
--color-text-secondary: #C2BFB4;
|
||||
--color-text-muted: #9C9A92;
|
||||
--color-border: #3F4651;
|
||||
--color-input-border: #3F4651;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.15);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--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: #818cf8;
|
||||
--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: #16161d;
|
||||
--color-code-inline-bg: #1e1e2d;
|
||||
--color-table-stripe: #16161e;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #facc15;
|
||||
--color-input-bar-bg: #1e1e27;
|
||||
--color-input-bar-text: #e4e4f0;
|
||||
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.18);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #14171A;
|
||||
--color-code-inline-bg: #1E2228;
|
||||
--color-table-stripe: rgba(255, 255, 255, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #1E2228;
|
||||
--color-input-bar-text: #E8E4D8;
|
||||
--color-input-bar-placeholder: rgba(232, 228, 216, 0.35);
|
||||
--color-overlay: rgba(0, 0, 0, 0.65);
|
||||
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
|
||||
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
|
||||
--color-bubble-user-text: #b0b0c8;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(99, 102, 241, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #3F4651;
|
||||
--color-bubble-user-text: #C2BFB4;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.10);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.14);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.22);
|
||||
|
||||
/* Action color set — identical across themes */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -113,15 +154,34 @@ body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
font-feature-settings: "cv11";
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
h1, h2 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
code, pre, kbd, samp {
|
||||
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-feature-settings: "liga", "calt";
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(91, 74, 138, 0.3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
@@ -159,7 +219,7 @@ button:not(:disabled):active,
|
||||
}
|
||||
}
|
||||
|
||||
/* Thin indigo-tinted scrollbars */
|
||||
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
@@ -168,11 +228,11 @@ button:not(:disabled):active,
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(99, 102, 241, 0.45);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
import { Sun, Moon, Settings } from "lucide-vue-next";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
@@ -16,7 +17,7 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const isChatActive = computed(() => route.path.startsWith("/chat"))
|
||||
const isKnowledgeActive = computed(() => route.path === "/" || route.path === "/knowledge");
|
||||
const isKnowledgeActive = computed(() => route.path === "/knowledge");
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
|
||||
@@ -68,18 +69,18 @@ router.afterEach(() => {
|
||||
<!-- Left: brand -->
|
||||
<router-link to="/" class="nav-brand">
|
||||
<AppLogo :size="34" />
|
||||
Fabled Assistant
|
||||
<span class="brand-text">Fabled</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<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="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/knowledge" 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="/journal" class="nav-link">Journal</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: status + utilities + gear + user -->
|
||||
@@ -94,15 +95,13 @@ router.afterEach(() => {
|
||||
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
|
||||
|
||||
<button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
||||
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
||||
<Sun v-if="theme === 'dark'" :size="16" />
|
||||
<Moon v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Settings link -->
|
||||
<router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<Settings :size="16" />
|
||||
</router-link>
|
||||
|
||||
<div class="user-info">
|
||||
@@ -122,13 +121,11 @@ router.afterEach(() => {
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/knowledge" 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="/journal" class="nav-link">Journal</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
@@ -139,7 +136,8 @@ router.afterEach(() => {
|
||||
<span class="status-text">{{ statusShortLabel }}</span>
|
||||
</span>
|
||||
<button class="btn-icon" @click="toggleShortcuts">?</button>
|
||||
<button class="btn-icon" @click="toggleTheme">{{ theme === "dark" ? "\u2600" : "\u263E" }}</button>
|
||||
<button class="btn-icon" @click="toggleTheme"><Sun v-if="theme === 'dark'" :size="16" />
|
||||
<Moon v-else :size="16" /></button>
|
||||
</div>
|
||||
<div class="mobile-user">
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
@@ -152,40 +150,50 @@ router.afterEach(() => {
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: var(--color-bg-secondary);
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Left */
|
||||
/* Left — brand */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-primary);
|
||||
gap: 0.45rem;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-text {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: #c4b0f0;
|
||||
}
|
||||
|
||||
/* Center — absolutely positioned so it's truly centered regardless of side widths */
|
||||
/* Center — pill bar */
|
||||
.nav-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.nav-pill-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--color-primary-faint);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Right */
|
||||
@@ -199,20 +207,20 @@ router.afterEach(() => {
|
||||
.nav-link {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
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-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
color: var(--color-text);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
border-radius: 0;
|
||||
color: var(--color-primary-solid);
|
||||
font-weight: 500;
|
||||
background: rgba(91, 74, 138, 0.25);
|
||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -234,15 +242,23 @@ router.afterEach(() => {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); }
|
||||
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; }
|
||||
/* Status dots are indicator lights, not semantic-palette buttons —
|
||||
they want to read as vital (Moss/Warning/Error are too muted for
|
||||
a "ready" indicator). Hardcoded bright values; the rest of the
|
||||
system still uses the semantic tokens. */
|
||||
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
|
||||
.status-orange .status-dot { background: #f97316; }
|
||||
.status-red .status-dot { background: var(--color-danger, #e74c3c); }
|
||||
.status-red .status-dot { background: #ef4444; }
|
||||
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@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); }
|
||||
}
|
||||
|
||||
/* Icon buttons (?, theme, gear) */
|
||||
.btn-icon {
|
||||
@@ -281,7 +297,7 @@ router.afterEach(() => {
|
||||
}
|
||||
.admin-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-primary);
|
||||
@@ -369,6 +385,11 @@ router.afterEach(() => {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mobile-menu .nav-link.router-link-active {
|
||||
background: var(--color-primary-wash);
|
||||
box-shadow: none;
|
||||
}
|
||||
.mobile-user .btn-logout {
|
||||
min-height: 36px;
|
||||
|
||||
@@ -11,6 +11,12 @@ defineProps<{ size?: number }>();
|
||||
:height="size ?? 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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>
|
||||
<!-- Book body -->
|
||||
<path class="logo-book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
|
||||
<!-- Book spine -->
|
||||
@@ -37,7 +43,7 @@ defineProps<{ size?: number }>();
|
||||
|
||||
<style scoped>
|
||||
.logo-book {
|
||||
fill: var(--color-primary);
|
||||
fill: url(#logo-gradient);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
.logo-spine {
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import {
|
||||
saveBriefingConfig,
|
||||
geocodeAddress,
|
||||
createBriefingFeed,
|
||||
type BriefingConfig,
|
||||
} from '@/api/client'
|
||||
|
||||
const emit = defineEmits<{ done: [] }>()
|
||||
|
||||
const step = ref(1)
|
||||
const TOTAL_STEPS = 4
|
||||
|
||||
const config = reactive<BriefingConfig>({
|
||||
enabled: true,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
})
|
||||
|
||||
// Step 2 — locations
|
||||
const homeAddress = ref('')
|
||||
const workAddress = ref('')
|
||||
const geocodingHome = ref(false)
|
||||
const geocodingWork = ref(false)
|
||||
const homeConfirmed = ref<string | null>(null)
|
||||
const workConfirmed = ref<string | null>(null)
|
||||
const homeError = ref('')
|
||||
const workError = ref('')
|
||||
|
||||
async function lookupHome() {
|
||||
if (!homeAddress.value.trim()) return
|
||||
geocodingHome.value = true
|
||||
homeError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(homeAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.home = { label: 'Home', address: homeAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
homeConfirmed.value = r.display_name
|
||||
} else {
|
||||
homeError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingHome.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupWork() {
|
||||
if (!workAddress.value.trim()) return
|
||||
geocodingWork.value = true
|
||||
workError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(workAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.work = { label: 'Work', address: workAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
workConfirmed.value = r.display_name
|
||||
} else {
|
||||
workError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingWork.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — work days
|
||||
function toggleDay(d: number) {
|
||||
const idx = config.work_days.indexOf(d)
|
||||
if (idx === -1) config.work_days.push(d)
|
||||
else config.work_days.splice(idx, 1)
|
||||
config.work_days.sort()
|
||||
}
|
||||
|
||||
// Step 4 — RSS feeds
|
||||
const newFeedUrl = ref('')
|
||||
const pendingFeeds = ref<Array<{ url: string }>>([])
|
||||
|
||||
function addPendingFeed() {
|
||||
if (!newFeedUrl.value.trim()) return
|
||||
pendingFeeds.value.push({ url: newFeedUrl.value.trim() })
|
||||
newFeedUrl.value = ''
|
||||
}
|
||||
function removePendingFeed(i: number) { pendingFeeds.value.splice(i, 1) }
|
||||
|
||||
// Finish
|
||||
const finishing = ref(false)
|
||||
async function finish() {
|
||||
finishing.value = true
|
||||
try {
|
||||
await saveBriefingConfig(config)
|
||||
for (const f of pendingFeeds.value) {
|
||||
try { await createBriefingFeed(f.url) } catch { /* best-effort */ }
|
||||
}
|
||||
emit('done')
|
||||
} finally {
|
||||
finishing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="wizard-overlay">
|
||||
<div class="wizard-card" role="dialog" aria-label="Briefing Setup">
|
||||
<!-- Progress bar -->
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-progress-fill" :style="{ width: `${(step / TOTAL_STEPS) * 100}%` }"></div>
|
||||
</div>
|
||||
<div class="wizard-step-label">Step {{ step }} of {{ TOTAL_STEPS }}</div>
|
||||
|
||||
<!-- Step 1: Welcome -->
|
||||
<div v-if="step === 1" class="wizard-body">
|
||||
<h2 class="wizard-title">Good morning.</h2>
|
||||
<p class="wizard-text">
|
||||
The Briefing is a daily conversation that summarises your day — tasks, calendar,
|
||||
weather, and news — and checks in a few times throughout the day.
|
||||
</p>
|
||||
<p class="wizard-text">
|
||||
It learns from your responses over time and adapts to your schedule.
|
||||
Let's take a minute to set it up.
|
||||
</p>
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-primary" @click="step = 2">Get started</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Locations -->
|
||||
<div v-else-if="step === 2" class="wizard-body">
|
||||
<h2 class="wizard-title">Where are you?</h2>
|
||||
<p class="wizard-text">
|
||||
Enter your home and work addresses. The briefing uses these to fetch weather
|
||||
for the right places. You can skip either one.
|
||||
</p>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Home address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="homeAddress" class="wizard-input" placeholder="e.g. 123 Main St, Springfield" @keydown.enter="lookupHome" />
|
||||
<button class="btn-wizard-secondary" @click="lookupHome" :disabled="geocodingHome">
|
||||
{{ geocodingHome ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="homeConfirmed" class="wizard-confirmed">✓ {{ homeConfirmed }}</div>
|
||||
<div v-if="homeError" class="wizard-error">{{ homeError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Work address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="workAddress" class="wizard-input" placeholder="e.g. 456 Office Ave, Portland" @keydown.enter="lookupWork" />
|
||||
<button class="btn-wizard-secondary" @click="lookupWork" :disabled="geocodingWork">
|
||||
{{ geocodingWork ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="workConfirmed" class="wizard-confirmed">✓ {{ workConfirmed }}</div>
|
||||
<div v-if="workError" class="wizard-error">{{ workError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 1">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 3">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Work schedule -->
|
||||
<div v-else-if="step === 3" class="wizard-body">
|
||||
<h2 class="wizard-title">When do you go to the office?</h2>
|
||||
<p class="wizard-text">
|
||||
The 8am slot is labelled "you're at the office" on days you have marked as office days.
|
||||
Toggle any days you typically commute.
|
||||
</p>
|
||||
|
||||
<div class="wizard-days">
|
||||
<button
|
||||
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="idx"
|
||||
:class="['wizard-day-btn', { active: config.work_days.includes(idx) }]"
|
||||
@click="toggleDay(idx)"
|
||||
type="button"
|
||||
>{{ day }}</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 2">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 4">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: RSS feeds -->
|
||||
<div v-else-if="step === 4" class="wizard-body">
|
||||
<h2 class="wizard-title">What do you follow?</h2>
|
||||
<p class="wizard-text">
|
||||
Add RSS or Atom feeds and the briefing will summarise recent items each morning.
|
||||
You can add or remove feeds any time in Settings.
|
||||
</p>
|
||||
|
||||
<div v-if="pendingFeeds.length" class="wizard-feeds-list">
|
||||
<div v-for="(f, i) in pendingFeeds" :key="i" class="wizard-feed-row">
|
||||
<span class="wizard-feed-url">{{ f.url }}</span>
|
||||
<button class="btn-wizard-remove" @click="removePendingFeed(i)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-add-feed">
|
||||
<input v-model="newFeedUrl" class="wizard-input" placeholder="https://…/feed.xml" style="flex: 1" />
|
||||
<button class="btn-wizard-secondary" @click="addPendingFeed" :disabled="!newFeedUrl.trim()">Add</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions" style="margin-top: 1.5rem">
|
||||
<button class="btn-wizard-ghost" @click="step = 3">Back</button>
|
||||
<button class="btn-wizard-ghost" @click="finish" :disabled="finishing">Skip</button>
|
||||
<button class="btn-wizard-primary" @click="finish" :disabled="finishing">
|
||||
{{ finishing ? 'Setting up…' : 'Enable Briefing' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wizard-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.wizard-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
width: 520px;
|
||||
max-width: 94vw;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.wizard-progress {
|
||||
height: 3px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.wizard-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1, #4f46e5);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.wizard-step-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
padding: 0.5rem 1.5rem 0;
|
||||
}
|
||||
.wizard-body {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
}
|
||||
.wizard-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.wizard-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.wizard-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.wizard-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.wizard-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wizard-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-input:focus { border-color: var(--color-primary); }
|
||||
.wizard-confirmed {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-success, #22c55e);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-error {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-danger, #ef4444);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-days {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.wizard-day-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-day-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.wizard-feeds-list {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.wizard-feed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wizard-feed-url {
|
||||
flex: 1;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-wizard-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-wizard-remove:hover { color: var(--color-danger, #ef4444); }
|
||||
.wizard-add-feed {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-wizard-primary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-secondary {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-ghost {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-ghost:hover { background: var(--color-bg-secondary); }
|
||||
.btn-wizard-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -1,12 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { apiGet, transcribeAudio } from '@/api/client'
|
||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
||||
import { useSilenceDetector } from '@/composables/useSilenceDetector'
|
||||
import { useVad } from '@/composables/useVad'
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
||||
import { useListenMode } from '@/composables/useListenMode'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { Note } from '@/types/note'
|
||||
import {
|
||||
Paperclip,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Square,
|
||||
Mic,
|
||||
Loader2,
|
||||
ArrowUp,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Textarea placeholder */
|
||||
@@ -29,6 +41,32 @@ const emit = defineEmits<{
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
|
||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||||
|
||||
// ── Streaming TTS (listen mode) ───────────────────────────────────────────────
|
||||
const listenMode = useListenMode()
|
||||
const audio = useVoiceAudio()
|
||||
const speakerPopoverOpen = ref(false)
|
||||
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => store.streamingContent),
|
||||
streaming: computed(() => store.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
|
||||
function lastAssistantContent(): string {
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
|
||||
}
|
||||
|
||||
function toggleListen() {
|
||||
listenMode.value = !listenMode.value
|
||||
if (listenMode.value) {
|
||||
tts.speak(lastAssistantContent())
|
||||
} else {
|
||||
tts.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core input ────────────────────────────────────────────────────────────────
|
||||
const messageInput = ref('')
|
||||
@@ -109,15 +147,50 @@ function removeAttachedNote() {
|
||||
attachedNote.value = null
|
||||
}
|
||||
|
||||
// ── Voice (click-to-toggle + silence detection) ─────────────────────────────
|
||||
// ── Voice (click-to-toggle + VAD speech detection) ─────────────────────────
|
||||
const transcribingVoice = ref(false)
|
||||
const recorder = useVoiceRecorder()
|
||||
const silenceDetector = useSilenceDetector()
|
||||
// Tracks whether VAD detected speech during the current recording session.
|
||||
// Used to skip the Whisper round-trip when the user manually stops without
|
||||
// ever speaking — the recording is ambient noise and will transcribe to "".
|
||||
const vadSawSpeech = ref(false)
|
||||
|
||||
const vad = useVad({
|
||||
onSpeechEnd: () => {
|
||||
// VAD auto-stop: speech was detected and the user has paused. Proceed
|
||||
// to transcribe the MediaRecorder output.
|
||||
void stopRecording(false)
|
||||
},
|
||||
onNoSpeech: () => {
|
||||
useToastStore().show('No speech detected', 'warning')
|
||||
},
|
||||
onVadError: (msg) => {
|
||||
useToastStore().show(`VAD failed: ${msg}`, 'error')
|
||||
},
|
||||
})
|
||||
|
||||
// Live mic halo. A solid red disc sits behind the mic button and scales
|
||||
// with `vad.amplitude` (0..1 RMS, already amplified for visibility).
|
||||
const micGlowStyle = computed(() => {
|
||||
if (!recorder.recording.value) return { display: 'none' }
|
||||
const pulse = 0.2 + Math.min(vad.amplitude.value, 1) * 0.8
|
||||
return {
|
||||
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
|
||||
opacity: String(0.45 + pulse * 0.4),
|
||||
}
|
||||
})
|
||||
|
||||
// vad.speaking flips true on speech-start. Watch it once per session to
|
||||
// capture that speech was detected at some point, without clearing when
|
||||
// speech ends. This drives the no-speech guard in stopRecording.
|
||||
watch(() => vad.speaking.value, (on) => {
|
||||
if (on) vadSawSpeech.value = true
|
||||
})
|
||||
|
||||
async function toggleVoice() {
|
||||
if (transcribingVoice.value) return
|
||||
if (recorder.recording.value) {
|
||||
await stopRecording()
|
||||
await stopRecording(true)
|
||||
} else {
|
||||
await startRecording()
|
||||
}
|
||||
@@ -129,22 +202,32 @@ async function startRecording() {
|
||||
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
|
||||
return
|
||||
}
|
||||
vadSawSpeech.value = false
|
||||
await recorder.startRecording()
|
||||
if (recorder.error.value) {
|
||||
useToastStore().show(recorder.error.value, 'error')
|
||||
return
|
||||
}
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, () => stopRecording())
|
||||
await vad.start(recorder.stream.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
silenceDetector.stop()
|
||||
async function stopRecording(manual: boolean) {
|
||||
if (manual) {
|
||||
await vad.stopAndCheck()
|
||||
} else {
|
||||
await vad.stop()
|
||||
}
|
||||
if (!recorder.recording.value) return
|
||||
transcribingVoice.value = true
|
||||
try {
|
||||
const blob = await recorder.stopRecording()
|
||||
// No-speech guard: user manually stopped without VAD seeing speech.
|
||||
// Skip Whisper — the toast was already shown by onNoSpeech.
|
||||
if (manual && !vadSawSpeech.value) {
|
||||
return
|
||||
}
|
||||
// Pass last assistant message as context to reduce STT mishearings
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
|
||||
@@ -159,6 +242,15 @@ async function stopRecording() {
|
||||
finally { transcribingVoice.value = false }
|
||||
}
|
||||
|
||||
// ── Click-outside to dismiss speaker popover ──────────────────────────────────
|
||||
function onDocumentMousedown(e: MouseEvent) {
|
||||
if (!speakerPopoverOpen.value) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target?.closest('.speaker-wrapper')) speakerPopoverOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('mousedown', onDocumentMousedown))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', onDocumentMousedown))
|
||||
|
||||
// ── Exposed interface ─────────────────────────────────────────────────────────
|
||||
function focus() {
|
||||
inputEl.value?.focus()
|
||||
@@ -194,9 +286,7 @@ defineExpose({ focus, prefill })
|
||||
:disabled="!store.chatReady"
|
||||
title="Attach a note"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
<Paperclip :size="16" />
|
||||
</button>
|
||||
<div v-if="showNotePicker" class="note-picker-dropdown">
|
||||
<input
|
||||
@@ -230,23 +320,64 @@ defineExpose({ focus, prefill })
|
||||
class="input-textarea"
|
||||
></textarea>
|
||||
|
||||
<!-- PTT mic -->
|
||||
<button
|
||||
v-if="voiceEnabled"
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Speaker / listen-mode popover -->
|
||||
<div v-if="voiceTtsEnabled" class="speaker-wrapper">
|
||||
<button
|
||||
class="btn-icon btn-speaker"
|
||||
:class="{ 'speaker-active': listenMode, 'speaker-busy': tts.speaking.value }"
|
||||
@click="speakerPopoverOpen = !speakerPopoverOpen"
|
||||
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
|
||||
aria-label="Listen and volume settings"
|
||||
>
|
||||
<Volume2 v-if="!tts.speaking.value" :size="16" />
|
||||
<VolumeX v-else :size="16" />
|
||||
</button>
|
||||
<div v-if="speakerPopoverOpen" class="speaker-popover">
|
||||
<button
|
||||
class="speaker-row speaker-row--toggle"
|
||||
:class="{ 'speaker-row--active': listenMode }"
|
||||
@click="toggleListen"
|
||||
>
|
||||
<span class="speaker-row-label">{{ listenMode ? 'Listening' : 'Read aloud' }}</span>
|
||||
<span class="speaker-switch" :class="{ on: listenMode }"></span>
|
||||
</button>
|
||||
<div class="speaker-row">
|
||||
<span class="speaker-row-label">Volume</span>
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
:value="audio.volume.value"
|
||||
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
|
||||
class="speaker-volume-range"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<span class="speaker-volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="tts.speaking.value"
|
||||
class="speaker-row speaker-row--stop"
|
||||
@click="tts.stop()"
|
||||
>
|
||||
<Square :size="16" fill="currentColor" />
|
||||
<span>Stop playback</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PTT mic (with live amplitude halo behind) -->
|
||||
<div v-if="voiceEnabled" class="mic-wrapper">
|
||||
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
|
||||
<button
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<Mic v-if="!transcribingVoice" :size="16" />
|
||||
<Loader2 v-else :size="16" class="mic-loader" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Abort (streaming) or Send -->
|
||||
<button
|
||||
@@ -255,14 +386,14 @@ defineExpose({ focus, prefill })
|
||||
@click="emit('abort')"
|
||||
title="Stop generation"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
<Square :size="16" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-send"
|
||||
@click="onSubmit"
|
||||
:disabled="!messageInput.trim() || !store.chatReady"
|
||||
>↑</button>
|
||||
><ArrowUp :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -343,8 +474,51 @@ defineExpose({ focus, prefill })
|
||||
.btn-icon:hover { opacity: 1; }
|
||||
.btn-icon:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.btn-mic.mic-recording { opacity: 1; color: #ef4444; }
|
||||
.btn-mic.mic-recording {
|
||||
opacity: 1;
|
||||
/* White icon sits on top of the red halo so it stays legible at any
|
||||
pulse size. */
|
||||
color: #fff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.btn-mic.mic-transcribing { opacity: 0.5; }
|
||||
.mic-loader { animation: mic-spin 1s linear infinite; }
|
||||
@keyframes mic-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
|
||||
absolutely positioned behind the button, scaled/faded by the
|
||||
computed `micGlowStyle` so the user gets unmistakable feedback
|
||||
that their voice is being picked up while the mic icon itself
|
||||
stays put and readable on top. */
|
||||
.mic-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mic-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(239, 68, 68, 0.9) 0%,
|
||||
rgba(239, 68, 68, 0.6) 55%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
|
||||
rather than stepping. */
|
||||
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.note-picker-wrapper { position: relative; }
|
||||
.note-picker-dropdown {
|
||||
@@ -391,7 +565,7 @@ defineExpose({ focus, prefill })
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
@@ -400,7 +574,7 @@ defineExpose({ focus, prefill })
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
|
||||
.btn-send:hover { box-shadow: 0 0 16px rgba(91, 74, 138, 0.35); }
|
||||
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
|
||||
|
||||
.btn-abort-inline {
|
||||
@@ -418,4 +592,77 @@ defineExpose({ focus, prefill })
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
|
||||
|
||||
/* Speaker / listen-mode popover */
|
||||
.speaker-wrapper { position: relative; display: inline-flex; flex-shrink: 0; }
|
||||
.btn-speaker.speaker-active { opacity: 1; color: var(--color-primary); }
|
||||
.btn-speaker.speaker-busy { opacity: 1; color: #f59e0b; }
|
||||
.speaker-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
padding: 0.35rem;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.speaker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.speaker-row--toggle { cursor: pointer; }
|
||||
.speaker-row--toggle:hover { background: var(--color-bg-secondary); }
|
||||
.speaker-row--active { color: var(--color-primary); }
|
||||
.speaker-row--stop {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.speaker-row--stop:hover { color: #ef4444; background: var(--color-bg-secondary); }
|
||||
.speaker-row-label { flex: 1; }
|
||||
.speaker-volume-range { flex: 1; min-width: 0; }
|
||||
.speaker-volume-pct {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
.speaker-switch {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-border);
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.speaker-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-card);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.speaker-switch.on { background: var(--color-primary); }
|
||||
.speaker-switch.on::after { transform: translateX(12px); }
|
||||
</style>
|
||||
|
||||
@@ -41,6 +41,16 @@ function formatMs(ms: number | null | undefined): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
|
||||
|
||||
// Hide LEGACY system-role daily_prep messages from earlier versions. The
|
||||
// current journal prep is a normal assistant message (renders with proper
|
||||
// bubble styling). Old system-role rows lurking in existing conversations
|
||||
// would render as a flat unstyled block — filter them.
|
||||
const hideMessage = computed(() =>
|
||||
props.message.role === "system" && metadata.value.kind === "daily_prep"
|
||||
);
|
||||
|
||||
const timingParts = computed((): string[] => {
|
||||
const t = props.message.timing;
|
||||
if (!t) return [];
|
||||
@@ -57,7 +67,7 @@ const timingParts = computed((): string[] => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-message" :class="`role-${message.role}`">
|
||||
<div v-if="!hideMessage" class="chat-message" :class="`role-${message.role}`">
|
||||
<div class="message-group">
|
||||
<div class="message-bubble">
|
||||
<div class="message-header">
|
||||
@@ -145,7 +155,7 @@ const timingParts = computed((): string[] => {
|
||||
}
|
||||
.role-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
@@ -157,7 +167,6 @@ const timingParts = computed((): string[] => {
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
@@ -165,14 +174,14 @@ const timingParts = computed((): string[] => {
|
||||
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border-left: 2px solid rgba(129, 140, 248, 0.35);
|
||||
border-left: 2px solid rgba(91, 74, 138, 0.35);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thinking-summary {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -205,6 +214,12 @@ details[open] .thinking-summary::before {
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
/* Long-form line-height (1.7) on assistant bubbles per the design system —
|
||||
chat is a reading surface, not a snippet stream. User bubbles stay tighter. */
|
||||
.role-assistant .message-content {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
@@ -281,4 +296,5 @@ details[open] .thinking-summary::before {
|
||||
margin-right: 0.2rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
||||
import { useListenMode } from '@/composables/useListenMode'
|
||||
import { apiGet } from '@/api/client'
|
||||
import ChatMessage from '@/components/ChatMessage.vue'
|
||||
import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
|
||||
import ChatInputBar from '@/components/ChatInputBar.vue'
|
||||
import ToolCallCard from '@/components/ToolCallCard.vue'
|
||||
import type { Message, ToolCallRecord } from '@/types/chat'
|
||||
import { Mic, ChevronDown, X } from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant: 'full' | 'widget'
|
||||
@@ -34,33 +30,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// ── Voice / TTS ───────────────────────────────────────────────────────────────
|
||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||||
const listenMode = useListenMode()
|
||||
const audio = useVoiceAudio()
|
||||
const showVolumeSlider = ref(false)
|
||||
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => store.streamingContent),
|
||||
streaming: computed(() => store.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
|
||||
function lastAssistantContent(): string {
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
|
||||
}
|
||||
|
||||
function toggleListen() {
|
||||
listenMode.value = !listenMode.value
|
||||
if (listenMode.value) {
|
||||
tts.speak(lastAssistantContent())
|
||||
} else {
|
||||
tts.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scroll ────────────────────────────────────────────────────────────────────
|
||||
const messagesEl = ref<HTMLElement | null>(null)
|
||||
@@ -89,47 +58,6 @@ watch(() => store.streamingToolCalls, (calls) => {
|
||||
}, { deep: true })
|
||||
watch(() => store.streaming, (s) => { if (!s) _seenCalendarToolIdx.clear() })
|
||||
|
||||
// ── RAG scope chip (full, non-briefing, non-workspace) ────────────────────────
|
||||
const projects = ref<{ id: number; title: string }[]>([])
|
||||
const scopeDropdownOpen = ref(false)
|
||||
const scopePulse = ref(false)
|
||||
|
||||
const showScopeChip = computed(
|
||||
() => props.variant === 'full' && !props.briefingMode && !props.projectId
|
||||
)
|
||||
|
||||
const scopeLabel = computed(() => {
|
||||
const id = store.ragProjectId
|
||||
if (id === -1) return 'All notes'
|
||||
if (id === null) return 'Orphan notes'
|
||||
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const data = await apiGet<{ projects: { id: number; title: string }[] }>('/api/projects?status=active')
|
||||
projects.value = data.projects ?? []
|
||||
} catch {
|
||||
projects.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function onScopeSelect(value: number | null) {
|
||||
scopeDropdownOpen.value = false
|
||||
const convId = store.currentConversation?.id
|
||||
if (!convId) return
|
||||
await store.updateRagScope(convId, value)
|
||||
scopePulse.value = true
|
||||
setTimeout(() => { scopePulse.value = false }, 600)
|
||||
}
|
||||
|
||||
// Pulse when model-driven scope change fires
|
||||
watch(() => store.ragProjectId, () => {
|
||||
if (!showScopeChip.value) return
|
||||
scopePulse.value = true
|
||||
setTimeout(() => { scopePulse.value = false }, 600)
|
||||
})
|
||||
|
||||
// ── Note context (full variant — included / suggested / auto-injected notes) ──
|
||||
const includedNoteIds = ref<Set<number>>(new Set())
|
||||
const includedNotes = ref<{ id: number; title: string }[]>([])
|
||||
@@ -190,11 +118,92 @@ function removeIncludedNote(noteId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const hasContextSidebar = computed(
|
||||
() => props.variant === 'full' && !props.briefingMode && !props.projectId &&
|
||||
(autoInjectedNotes.value.length > 0 || includedNotes.value.length > 0 || suggestedNotes.value.length > 0)
|
||||
const contextCount = computed(
|
||||
() => autoInjectedNotes.value.length + suggestedNotes.value.length + includedNotes.value.length
|
||||
)
|
||||
|
||||
const hasContextData = computed(
|
||||
() => props.variant === 'full' && !props.briefingMode && !props.projectId && contextCount.value > 0
|
||||
)
|
||||
|
||||
// ── Narrow-viewport sidebar toggle ────────────────────────────────────────────
|
||||
const NARROW_BREAKPOINT = 1200
|
||||
const isNarrow = ref(typeof window !== 'undefined' && window.innerWidth < NARROW_BREAKPOINT)
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
function onResize() {
|
||||
isNarrow.value = window.innerWidth < NARROW_BREAKPOINT
|
||||
}
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
function toggleContextSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const hasContextSidebar = computed(() => hasContextData.value && sidebarOpen.value)
|
||||
|
||||
// ── Collapsible sections (per-conversation, localStorage) ─────────────────────
|
||||
type SectionKey = 'auto' | 'suggested' | 'included'
|
||||
const collapsedSections = ref<Set<SectionKey>>(new Set())
|
||||
|
||||
function storageKey(): string | null {
|
||||
const id = store.currentConversation?.id
|
||||
return id ? `fa_chat_ctx_collapsed_${id}` : null
|
||||
}
|
||||
|
||||
function loadCollapsed() {
|
||||
const key = storageKey()
|
||||
if (!key) { collapsedSections.value = new Set(); return }
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) { collapsedSections.value = new Set(); return }
|
||||
const arr = JSON.parse(raw) as SectionKey[]
|
||||
collapsedSections.value = new Set(arr)
|
||||
} catch {
|
||||
collapsedSections.value = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsed() {
|
||||
const key = storageKey()
|
||||
if (!key) return
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify([...collapsedSections.value]))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function toggleSection(key: SectionKey) {
|
||||
const next = new Set(collapsedSections.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
collapsedSections.value = next
|
||||
saveCollapsed()
|
||||
}
|
||||
|
||||
watch(() => store.currentConversation?.id, () => loadCollapsed(), { immediate: true })
|
||||
|
||||
// ── Empty-state (full variant, fresh/empty conversation) ─────────────────────
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
props.variant === 'full'
|
||||
&& !props.briefingMode
|
||||
&& !props.projectId
|
||||
&& !store.streaming
|
||||
&& (store.currentConversation?.messages.length ?? 0) === 0
|
||||
)
|
||||
|
||||
const recentConversations = computed(() => {
|
||||
const currentId = store.currentConversation?.id
|
||||
return store.conversations
|
||||
.filter((c) => c.id !== currentId && c.message_count > 0)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
function startVoiceMode() {
|
||||
window.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
||||
}
|
||||
|
||||
// ── Send ──────────────────────────────────────────────────────────────────────
|
||||
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null)
|
||||
|
||||
@@ -208,7 +217,7 @@ async function onSubmit(payload: { content: string; contextNoteId?: number }) {
|
||||
payload.content,
|
||||
payload.contextNoteId,
|
||||
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
|
||||
true,
|
||||
false,
|
||||
undefined,
|
||||
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
|
||||
props.projectId ?? store.ragProjectId,
|
||||
@@ -249,7 +258,7 @@ async function widgetSend(payload: { content: string; contextNoteId?: number })
|
||||
widgetConvId.value = conv.id
|
||||
emit('conversation-started', conv.id)
|
||||
|
||||
await store.sendMessage(payload.content, payload.contextNoteId, undefined, true)
|
||||
await store.sendMessage(payload.content, payload.contextNoteId, undefined, false)
|
||||
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === 'assistant')
|
||||
@@ -271,8 +280,7 @@ async function handleSaveAsNote(messageId: number) {
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
if (showScopeChip.value) await loadProjects()
|
||||
onMounted(() => {
|
||||
if (props.autoFocus) nextTick(() => inputBarRef.value?.focus())
|
||||
})
|
||||
|
||||
@@ -289,13 +297,30 @@ async function send(text: string) {
|
||||
await onSubmit({ content: text })
|
||||
}
|
||||
|
||||
defineExpose({ focus, prefill, send })
|
||||
// ── Briefing slot separator helper ────────────────────────────────────────────
|
||||
function hasEarlierBriefingSlot(index: number): boolean {
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
for (let i = 0; i < index; i++) {
|
||||
const m = msgs[i]
|
||||
const meta = m?.metadata as Record<string, unknown> | null | undefined
|
||||
if (
|
||||
m?.role === 'assistant' &&
|
||||
meta?.briefing_slot != null &&
|
||||
!meta?.briefing_intermediate
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSidebar, hasContextData })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ═══════════════════════════════ FULL VARIANT ══════════════════════════════ -->
|
||||
<template v-if="variant === 'full'">
|
||||
<div class="chat-body" :class="{ 'chat-body--has-sidebar': hasContextSidebar }">
|
||||
<div class="chat-full">
|
||||
<!-- Message list -->
|
||||
<div ref="messagesEl" class="messages-container">
|
||||
<div class="messages-inner">
|
||||
@@ -303,13 +328,11 @@ defineExpose({ focus, prefill, send })
|
||||
v-for="(msg, index) in store.currentConversation?.messages ?? []"
|
||||
:key="msg.id"
|
||||
>
|
||||
<!-- Briefing slot separator: shown before a 2nd+ briefing slot message -->
|
||||
<!-- Briefing slot separator: before any non-first slot message (skip intermediate tool-call rows) -->
|
||||
<div
|
||||
v-if="briefingMode && index > 0 && msg.role === 'assistant' && msg.metadata?.rss_item_ids"
|
||||
v-if="briefingMode && msg.role === 'assistant' && msg.metadata?.briefing_slot && !msg.metadata?.briefing_intermediate && hasEarlierBriefingSlot(index)"
|
||||
class="briefing-slot-separator"
|
||||
>
|
||||
<span>New Briefing Update</span>
|
||||
</div>
|
||||
></div>
|
||||
<ChatMessage
|
||||
:message="msg"
|
||||
@save-as-note="handleSaveAsNote"
|
||||
@@ -335,124 +358,100 @@ defineExpose({ focus, prefill, send })
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="showEmptyState" class="chat-empty-state">
|
||||
<h2 class="empty-greeting">What's on your mind?</h2>
|
||||
<div v-if="recentConversations.length" class="empty-recent">
|
||||
<div class="empty-section-label">Jump back in</div>
|
||||
<router-link
|
||||
v-for="conv in recentConversations"
|
||||
:key="conv.id"
|
||||
:to="`/chat/${conv.id}`"
|
||||
class="empty-recent-item"
|
||||
>
|
||||
<span class="empty-recent-title">{{ conv.title || 'Untitled conversation' }}</span>
|
||||
<span class="empty-recent-count">{{ conv.message_count }} msg</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<button class="empty-voice-btn" @click="startVoiceMode" title="Start in voice mode">
|
||||
<Mic class="voice-icon" :size="16" />
|
||||
<span>Start in voice mode</span>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="!store.currentConversation?.messages.length && !store.streaming"
|
||||
v-else-if="!store.currentConversation?.messages.length && !store.streaming"
|
||||
class="empty-msg"
|
||||
>Send a message to start the conversation.</p>
|
||||
>Start a conversation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context sidebar (full, non-briefing, non-workspace) -->
|
||||
<aside v-if="hasContextSidebar" class="context-sidebar">
|
||||
<template v-if="autoInjectedNotes.length">
|
||||
<div class="context-sidebar-header">Auto-included</div>
|
||||
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)">×</button>
|
||||
<button
|
||||
class="context-sidebar-header"
|
||||
:class="{ collapsed: collapsedSections.has('auto') }"
|
||||
@click="toggleSection('auto')"
|
||||
>
|
||||
<ChevronDown class="chev" :size="16" />
|
||||
<span>Auto-included</span>
|
||||
<span class="section-count">{{ autoInjectedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('auto')">
|
||||
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
|
||||
<span class="auto-pill" title="Auto-included by relevance">AUTO</span>
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="suggestedNotes.length">
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
|
||||
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
|
||||
<button
|
||||
class="context-sidebar-header"
|
||||
:class="{ collapsed: collapsedSections.has('suggested'), 'context-sidebar-header-gap': autoInjectedNotes.length }"
|
||||
@click="toggleSection('suggested')"
|
||||
>
|
||||
<ChevronDown class="chev" :size="16" />
|
||||
<span>Suggested</span>
|
||||
<span class="section-count">{{ suggestedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('suggested')">
|
||||
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="includedNotes.length">
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)">×</button>
|
||||
<button
|
||||
class="context-sidebar-header"
|
||||
:class="{ collapsed: collapsedSections.has('included'), 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }"
|
||||
@click="toggleSection('included')"
|
||||
>
|
||||
<ChevronDown class="chev" :size="16" />
|
||||
<span>In Context</span>
|
||||
<span class="section-count">{{ includedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('included')">
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Input area (hidden when readOnly) -->
|
||||
<div v-if="!readOnly" class="input-wrapper">
|
||||
<!-- Scope chip -->
|
||||
<div v-if="showScopeChip" class="scope-chip-row">
|
||||
<div class="scope-chip-wrapper">
|
||||
<button
|
||||
class="scope-chip"
|
||||
:class="{ pulse: scopePulse }"
|
||||
@click="scopeDropdownOpen = !scopeDropdownOpen"
|
||||
title="Change RAG scope"
|
||||
>
|
||||
<span class="scope-dot">⊙</span> {{ scopeLabel }}
|
||||
</button>
|
||||
<div v-if="scopeDropdownOpen" class="scope-dropdown">
|
||||
<button class="scope-option" :class="{ active: store.ragProjectId === null }" @click="onScopeSelect(null)">Orphan notes only</button>
|
||||
<button
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === p.id }"
|
||||
@click="onScopeSelect(p.id)"
|
||||
>{{ p.title }}</button>
|
||||
<button class="scope-option" :class="{ active: store.ragProjectId === -1 }" @click="onScopeSelect(-1)">All notes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listen / volume controls -->
|
||||
<div v-if="voiceTtsEnabled" class="voice-controls">
|
||||
<div class="volume-wrapper">
|
||||
<button
|
||||
class="btn-voice"
|
||||
:class="{ 'btn-voice--active': listenMode, 'btn-voice--busy': tts.speaking.value }"
|
||||
@click="toggleListen"
|
||||
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
|
||||
aria-label="Toggle listen mode"
|
||||
>
|
||||
<svg v-if="!tts.speaking.value" width="17" height="17" 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="17" height="17" 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
|
||||
class="btn-voice btn-volume-icon"
|
||||
@click="showVolumeSlider = !showVolumeSlider"
|
||||
:title="`Volume: ${Math.round(audio.volume.value * 100)}%`"
|
||||
aria-label="Volume"
|
||||
>
|
||||
<svg width="14" height="14" 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.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showVolumeSlider" class="volume-popup">
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
:value="audio.volume.value"
|
||||
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
|
||||
class="volume-range"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<span class="volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="tts.speaking.value"
|
||||
class="btn-voice"
|
||||
@click="tts.stop()"
|
||||
title="Stop playback"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input area (hidden when readOnly) -->
|
||||
<div v-if="!readOnly" class="input-wrapper">
|
||||
<!-- Unified input bar -->
|
||||
<ChatInputBar
|
||||
ref="inputBarRef"
|
||||
@@ -461,6 +460,7 @@ defineExpose({ focus, prefill, send })
|
||||
@submit="onSubmit"
|
||||
@abort="store.cancelGeneration()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -513,18 +513,33 @@ defineExpose({ focus, prefill, send })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Full variant layout ── */
|
||||
.chat-body {
|
||||
/* ── Full variant layout ──
|
||||
* Single grid owns the reading column + context sidebar + input bar so
|
||||
* messages and input bar share one centered reading track while the
|
||||
* context sidebar spans the full height from header to bottom of view.
|
||||
* Reading column is always centered (3-column grid: 1fr | content | 1fr).
|
||||
* The context sidebar overlays the right gutter so it never shifts layout.
|
||||
*/
|
||||
.chat-full {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
1fr
|
||||
minmax(0, var(--chat-reading-width))
|
||||
1fr;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
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);
|
||||
}
|
||||
.messages-inner {
|
||||
display: flex;
|
||||
@@ -535,51 +550,85 @@ defineExpose({ focus, prefill, send })
|
||||
|
||||
/* Briefing slot separator */
|
||||
.briefing-slot-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.briefing-slot-separator::before,
|
||||
.briefing-slot-separator::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
background: var(--color-border, #333);
|
||||
opacity: 0.5;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Context sidebar */
|
||||
/* Context sidebar — overlays right gutter, never shifts reading column */
|
||||
.context-sidebar {
|
||||
width: 200px;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--chat-context-sidebar-width);
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: 0.75rem 0.5rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.78rem;
|
||||
z-index: 2;
|
||||
}
|
||||
.context-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.15rem 0.1rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.context-sidebar-header:hover { color: var(--color-text); }
|
||||
.context-sidebar-header .chev {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.context-sidebar-header.collapsed .chev {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.context-sidebar-header .section-count {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.context-sidebar-header-gap { margin-top: 0.75rem; }
|
||||
.context-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.2rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.context-note-auto {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 8%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.context-note-included {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
}
|
||||
.auto-pill {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.context-note-name {
|
||||
flex: 1;
|
||||
@@ -613,103 +662,16 @@ defineExpose({ focus, prefill, send })
|
||||
.context-note-remove:hover { color: #ef4444; }
|
||||
.context-note-add:hover { color: var(--color-primary); }
|
||||
|
||||
/* Input wrapper */
|
||||
/* Input wrapper — lives in the same reading column as messages */
|
||||
.input-wrapper {
|
||||
border-top: 1px solid var(--color-border);
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Scope chip */
|
||||
.scope-chip-row { display: flex; }
|
||||
.scope-chip-wrapper { position: relative; }
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
|
||||
@keyframes pulse-chip {
|
||||
0%, 100% { border-color: var(--color-border); }
|
||||
50% { border-color: var(--color-primary); color: var(--color-primary); box-shadow: 0 0 6px rgba(99,102,241,0.4); }
|
||||
}
|
||||
.scope-dot { font-size: 0.6rem; }
|
||||
.scope-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scope-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.75rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-option:hover { background: var(--color-bg-secondary); }
|
||||
.scope-option.active { color: var(--color-primary); font-weight: 600; }
|
||||
|
||||
/* Voice controls */
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.volume-wrapper { position: relative; display: flex; align-items: center; gap: 0.1rem; }
|
||||
.btn-voice {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.65;
|
||||
padding: 0.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-voice:hover { opacity: 1; }
|
||||
.btn-voice--active { opacity: 1; color: var(--color-primary); }
|
||||
.btn-voice--busy { opacity: 1; color: #f59e0b; }
|
||||
.volume-popup {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
z-index: 20;
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
}
|
||||
.volume-range { width: 80px; }
|
||||
.volume-pct { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
|
||||
|
||||
/* Queued messages */
|
||||
.queued-message { opacity: 0.45; }
|
||||
.queued-bubble {
|
||||
@@ -724,7 +686,7 @@ defineExpose({ focus, prefill, send })
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.queued-clear-row {
|
||||
@@ -742,10 +704,112 @@ defineExpose({ focus, prefill, send })
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
}
|
||||
|
||||
.chat-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1.5rem;
|
||||
padding: 3rem 1rem 2rem;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-greeting {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-recent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: var(--radius-md, 10px);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated, rgba(255, 255, 255, 0.02));
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.empty-recent-item + .empty-recent-item {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.empty-recent-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(91, 74, 138, 0.05);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.empty-recent-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-recent-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-voice-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.empty-voice-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: linear-gradient(135deg, #5B4A8A, #3F3560);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(91, 74, 138, 0.35);
|
||||
}
|
||||
|
||||
.empty-voice-btn .voice-icon {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* ── Widget variant ── */
|
||||
@@ -762,7 +826,6 @@ defineExpose({ focus, prefill, send })
|
||||
.widget-query {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.widget-text {
|
||||
font-size: 0.9rem;
|
||||
@@ -805,7 +868,7 @@ defineExpose({ focus, prefill, send })
|
||||
.btn-open-chat.prominent {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-clear-response {
|
||||
background: none;
|
||||
@@ -852,7 +915,7 @@ defineExpose({ focus, prefill, send })
|
||||
:deep(.thinking-summary) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -124,7 +124,6 @@ function markerFor(type: DiffLine['type']): string {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
@@ -153,7 +152,6 @@ function markerFor(type: DiffLine['type']): string {
|
||||
.diff-collapse {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
padding-top: 0.2rem;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<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";
|
||||
@@ -64,56 +65,181 @@ function toIso(date: string, time: string): string {
|
||||
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;
|
||||
// All-day events: use UTC date string directly to avoid timezone shifting
|
||||
// (UTC midnight parsed through new Date() becomes the previous day in UTC-X zones)
|
||||
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)) : "";
|
||||
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_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;
|
||||
_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 = "09:00";
|
||||
startTime.value = roundedStart;
|
||||
endDate.value = base;
|
||||
endTime.value = "10:00";
|
||||
endTime.value = addHours(roundedStart, 1);
|
||||
description.value = "";
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
_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") emit("close");
|
||||
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));
|
||||
|
||||
async function save() {
|
||||
// ── 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()) {
|
||||
toast.show("Title is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Title required" };
|
||||
}
|
||||
if (!startDate.value) {
|
||||
toast.show("Start date is required", "error");
|
||||
return;
|
||||
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))
|
||||
@@ -133,7 +259,6 @@ async function save() {
|
||||
project_id: projectId.value ?? undefined,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
toast.show("Event updated", "success");
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
@@ -147,7 +272,6 @@ async function save() {
|
||||
project_id: projectId.value ?? undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
toast.show("Event created", "success");
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
@@ -173,23 +297,57 @@ async function doDelete() {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="slide-over-backdrop" @click.self="emit('close')">
|
||||
<div class="slide-over-panel" role="dialog" aria-modal="true">
|
||||
<div class="so-header">
|
||||
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<button class="so-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||
<div 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>
|
||||
|
||||
<form class="so-form" @submit.prevent="save">
|
||||
<!-- Body: form (scrolls if it gets long) -->
|
||||
<form class="modal-form" @submit.prevent="attemptClose">
|
||||
<!-- Title -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="so-input" placeholder="Event title" autofocus />
|
||||
<div 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="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">All day</label>
|
||||
<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 }]"
|
||||
@@ -198,73 +356,55 @@ async function doDelete() {
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Start</label>
|
||||
<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="so-input dt-date" />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" />
|
||||
<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="so-field">
|
||||
<label class="so-label">End <span class="so-hint">(optional)</span></label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">End</label>
|
||||
<div class="dt-row">
|
||||
<input v-model="endDate" type="date" class="so-input dt-date" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
|
||||
<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>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Location <span class="so-hint">(optional)</span></label>
|
||||
<input v-model="location" class="so-input" placeholder="Location" />
|
||||
<div 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="so-field">
|
||||
<label class="so-label">Description <span class="so-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" />
|
||||
<div 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="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">Color</label>
|
||||
<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="so-input color-hex" placeholder="#6366f1" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''">✕</button>
|
||||
<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="so-field">
|
||||
<label class="so-label">Project <span class="so-hint">(optional)</span></label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
|
||||
<ProjectSelector v-model="projectId" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="so-actions">
|
||||
<button type="submit" class="btn-primary" :disabled="saving">
|
||||
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
|
||||
<template v-if="isEditMode">
|
||||
<button
|
||||
v-if="!deleteConfirm"
|
||||
type="button"
|
||||
class="btn-danger-ghost"
|
||||
@click="deleteConfirm = true"
|
||||
>Delete</button>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-label">Delete this event?</span>
|
||||
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
|
||||
{{ deleting ? "Deleting…" : "Yes, delete" }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 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>
|
||||
@@ -272,81 +412,110 @@ async function doDelete() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-over-backdrop {
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.slide-over-panel {
|
||||
.modal-panel {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border-left: 1px solid var(--color-border, #2a2b30);
|
||||
width: min(440px, 100vw);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
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: -4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.so-header {
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.85rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
z-index: 1;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.so-title {
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.so-close {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
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);
|
||||
}
|
||||
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.so-form {
|
||||
padding: 1.25rem 1.5rem;
|
||||
/* 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;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.so-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.so-label {
|
||||
.form-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.so-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
.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; }
|
||||
|
||||
.so-input {
|
||||
.form-input {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text, #e8e9f0);
|
||||
@@ -357,13 +526,18 @@ async function doDelete() {
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.so-input:focus { outline: none; border-color: var(--color-primary, #6366f1); }
|
||||
.form-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
.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);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--color-input-bg, #111113);
|
||||
@@ -376,8 +550,8 @@ async function doDelete() {
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -393,67 +567,43 @@ async function doDelete() {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.so-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2b30);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.btn-danger-ghost {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid #ef4444;
|
||||
color: #ef4444;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger-ghost:hover { background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
/* Confirm-delete buttons (only shown during the inline confirm flow) */
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
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; }
|
||||
|
||||
.delete-confirm-label {
|
||||
.btn-confirm-cancel {
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
align-self: center;
|
||||
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>
|
||||
|
||||
@@ -244,7 +244,6 @@ onMounted(loadVersions);
|
||||
padding: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history-footer {
|
||||
|
||||
@@ -94,10 +94,10 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
/* ── Streaming ── */
|
||||
.iap-streaming {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.iap-streaming .iap-header {
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 8%, var(--color-bg-secondary));
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.iap-pulse {
|
||||
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
animation: iap-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@@ -151,7 +151,6 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Review ── */
|
||||
@@ -172,8 +171,8 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
font-family: inherit;
|
||||
}
|
||||
.iap-btn-toggle:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.iap-actions {
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
Code,
|
||||
SquareCode,
|
||||
Quote,
|
||||
Link as LinkIcon,
|
||||
Brackets,
|
||||
} from "lucide-vue-next";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const props = defineProps<{ editor: Editor | null }>();
|
||||
|
||||
// SVG icons — Material Design paths (24x24) or custom SVG
|
||||
const ICONS: Record<string, string> = {
|
||||
bold: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>`,
|
||||
italic: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>`,
|
||||
strike: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/></svg>`,
|
||||
h1: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">1</text></svg>`,
|
||||
h2: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">2</text></svg>`,
|
||||
h3: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">3</text></svg>`,
|
||||
ul: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`,
|
||||
ol: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2zm1-9h1V4H2v1h1zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2zm5-3h13V6H7v2zm0 4h13v-2H7v2zm0 4h13v-2H7v2z"/></svg>`,
|
||||
task: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>`,
|
||||
code: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>`,
|
||||
codeblock: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM4 19h16v2H4z"/></svg>`,
|
||||
quote: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>`,
|
||||
link: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`,
|
||||
wikilink: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/></svg>`,
|
||||
const ICONS: Record<string, Component> = {
|
||||
bold: Bold,
|
||||
italic: Italic,
|
||||
strike: Strikethrough,
|
||||
h1: Heading1,
|
||||
h2: Heading2,
|
||||
h3: Heading3,
|
||||
ul: List,
|
||||
ol: ListOrdered,
|
||||
task: ListChecks,
|
||||
code: Code,
|
||||
codeblock: SquareCode,
|
||||
quote: Quote,
|
||||
link: LinkIcon,
|
||||
wikilink: Brackets,
|
||||
};
|
||||
|
||||
const groups = [
|
||||
@@ -78,9 +94,10 @@ const groups = [
|
||||
:class="['md-btn', { active: btn.isActive() }]"
|
||||
:title="btn.title"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent="btn.command()"
|
||||
>
|
||||
<span class="btn-icon" v-html="ICONS[btn.id]" />
|
||||
<component :is="ICONS[btn.id]" class="btn-icon" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="gi < groups.length - 1" class="toolbar-sep" aria-hidden="true" />
|
||||
|
||||
@@ -64,11 +64,11 @@ function goEdit() {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.note-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function goEdit() {
|
||||
}
|
||||
.note-card.compact:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(99, 102, 241, 0.04);
|
||||
background: rgba(91, 74, 138, 0.04);
|
||||
transform: none;
|
||||
}
|
||||
.note-title-compact {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import NotificationsPanel from './NotificationsPanel.vue'
|
||||
import { Bell } from 'lucide-vue-next'
|
||||
|
||||
const store = useNotificationsStore()
|
||||
const open = ref(false)
|
||||
@@ -45,10 +46,7 @@ onUnmounted(() => {
|
||||
aria-label="Notifications"
|
||||
:title="`${store.count} unread notification${store.count !== 1 ? 's' : ''}`"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<Bell :size="16" aria-hidden="true" />
|
||||
<span v-if="store.count > 0" class="bell-badge" aria-live="polite">{{ store.count > 99 ? '99+' : store.count }}</span>
|
||||
</button>
|
||||
<NotificationsPanel v-if="open" @close="close" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
@@ -56,7 +57,7 @@ onMounted(() => store.fetchAll())
|
||||
</p>
|
||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||
</div>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss">✕</button>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notif-empty">No unread notifications</div>
|
||||
@@ -151,6 +152,5 @@ onMounted(() => store.fetchAll())
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
searchUsers,
|
||||
@@ -116,7 +117,7 @@ onMounted(async () => {
|
||||
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
|
||||
<header class="share-header">
|
||||
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
</header>
|
||||
|
||||
<!-- Add share form -->
|
||||
@@ -183,7 +184,7 @@ onMounted(async () => {
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove">✕</button>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
|
||||
</li>
|
||||
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
|
||||
</ul>
|
||||
@@ -337,7 +338,7 @@ onMounted(async () => {
|
||||
|
||||
.btn-add-share {
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -409,7 +410,6 @@ onMounted(async () => {
|
||||
.shares-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ defineEmits<{
|
||||
}
|
||||
.tag-pill:hover {
|
||||
color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.dismiss {
|
||||
background: none;
|
||||
|
||||
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.task-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ async function confirmDuplicate() {
|
||||
if (confirmState.value !== "idle") return;
|
||||
confirmState.value = "creating";
|
||||
const args = props.toolCall.arguments as Record<string, unknown>;
|
||||
const isTask = props.toolCall.function === "create_task";
|
||||
const isTask = !!(args.status);
|
||||
const endpoint = isTask ? "/api/tasks" : "/api/notes";
|
||||
const payload: Record<string, unknown> = {
|
||||
title: args.title,
|
||||
@@ -532,10 +532,13 @@ function closeEventSlideOver(changed = false) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Inline ToolCallCard renders inside an assistant bubble — the bubble
|
||||
already contains it, so no outer border per the structural-not-decorative
|
||||
rule. Background tint differentiates it; error state gets a left-edge
|
||||
accent the way the assistant bubble itself uses one. */
|
||||
.tool-call-card {
|
||||
display: inline-block;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.4rem;
|
||||
@@ -543,7 +546,7 @@ function closeEventSlideOver(changed = false) {
|
||||
vertical-align: top;
|
||||
}
|
||||
.tool-call-card.error {
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
border-left: 3px solid var(--color-danger);
|
||||
}
|
||||
.tool-call-card.declined {
|
||||
opacity: 0.55;
|
||||
@@ -566,7 +569,7 @@ function closeEventSlideOver(changed = false) {
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -636,14 +639,14 @@ function closeEventSlideOver(changed = false) {
|
||||
}
|
||||
.web-search-results .tool-search-item::after { content: none; }
|
||||
|
||||
.tool-event-title { font-weight: 600; color: var(--color-text); }
|
||||
.tool-event-title { font-weight: 500; color: var(--color-text); }
|
||||
.tool-event-time { color: var(--color-text-muted); font-size: 0.75rem; }
|
||||
|
||||
.tool-event-list { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.tool-event-item { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; }
|
||||
.tool-event-item-title { color: var(--color-text); font-size: 0.8rem; }
|
||||
.tool-event-item-time { color: var(--color-text-muted); font-size: 0.75rem; white-space: nowrap; }
|
||||
.tool-event-more { color: var(--color-text-muted); font-size: 0.75rem; font-style: italic; }
|
||||
.tool-event-more { color: var(--color-text-muted); font-size: 0.75rem; }
|
||||
|
||||
.tool-deleted { text-decoration: line-through; opacity: 0.7; }
|
||||
.tool-completed { text-decoration: line-through; color: var(--color-success, #2ecc71); }
|
||||
@@ -652,7 +655,7 @@ function closeEventSlideOver(changed = false) {
|
||||
.tool-note-tag { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
|
||||
.tool-task-priority {
|
||||
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
|
||||
font-size: 0.7rem; font-weight: 500; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; padding: 0.1rem 0.3rem; border-radius: 3px;
|
||||
}
|
||||
.priority-high { color: var(--color-danger, #e74c3c); }
|
||||
@@ -697,12 +700,11 @@ function closeEventSlideOver(changed = false) {
|
||||
.confirm-created {
|
||||
color: var(--color-success, #2ecc71);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.confirm-denied {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.btn-confirm-duplicate {
|
||||
padding: 0.15rem 0.5rem;
|
||||
@@ -751,7 +753,7 @@ function closeEventSlideOver(changed = false) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -773,14 +775,14 @@ function closeEventSlideOver(changed = false) {
|
||||
}
|
||||
.tool-event-card.clickable { cursor: pointer; }
|
||||
.tool-event-card.clickable:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card, #16161a));
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card, #16161a));
|
||||
}
|
||||
.tool-event-card-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
@@ -792,7 +794,7 @@ function closeEventSlideOver(changed = false) {
|
||||
}
|
||||
.tool-event-card-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -202,7 +202,6 @@ function restore() {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.vh-item {
|
||||
|
||||
@@ -9,6 +9,8 @@ interface ForecastDay {
|
||||
precip_probability: number | null
|
||||
precip_mm: number | null
|
||||
windspeed_max: number
|
||||
precip_summary?: string
|
||||
precip_peak_hour?: string
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
@@ -21,6 +23,7 @@ interface WeatherData {
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
precip_summary?: string | null
|
||||
forecast: ForecastDay[]
|
||||
}
|
||||
|
||||
@@ -68,6 +71,11 @@ const fetchedAtLabel = computed(() => {
|
||||
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>
|
||||
@@ -85,22 +93,39 @@ const fetchedAtLabel = computed(() => {
|
||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
||||
</div>
|
||||
<div class="weather-forecast" v-if="weather.forecast.length">
|
||||
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
|
||||
<span class="forecast-day-name">{{ day.day }}</span>
|
||||
<span class="forecast-icon">{{ weatherIcon(day.condition) }}</span>
|
||||
<span class="forecast-condition">{{ day.condition }}</span>
|
||||
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
|
||||
<span v-if="day.precip_probability != null && day.precip_probability > 0" class="forecast-precip">
|
||||
💧 {{ day.precip_probability }}%
|
||||
</span>
|
||||
<span v-else-if="day.precip_mm != null && day.precip_mm > 0" class="forecast-precip">
|
||||
💧 {{ day.precip_mm.toFixed(1) }}mm
|
||||
</span>
|
||||
<span v-else class="forecast-precip forecast-precip--dry">💧 —</span>
|
||||
<span class="forecast-wind">💨 {{ day.windspeed_max }} {{ weather.wind_unit ?? 'km/h' }}</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.
|
||||
@@ -115,6 +140,7 @@ const fetchedAtLabel = computed(() => {
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.weather-header {
|
||||
@@ -142,12 +168,12 @@ const fetchedAtLabel = computed(() => {
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
font-size: 2rem;
|
||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weather-temp {
|
||||
font-size: 2rem;
|
||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -159,7 +185,7 @@ const fetchedAtLabel = computed(() => {
|
||||
|
||||
.weather-today {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -168,48 +194,59 @@ const fetchedAtLabel = computed(() => {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.weather-forecast {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
.weather-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-day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 4.5rem;
|
||||
.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: 1.2rem;
|
||||
font-size: clamp(0.9rem, 3cqi, 1.3rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.forecast-condition {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.forecast-temps {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.forecast-precip,
|
||||
.forecast-wind {
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
.forecast-precip {
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -217,8 +254,24 @@ const fetchedAtLabel = computed(() => {
|
||||
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);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import ChatPanel from '@/components/ChatPanel.vue';
|
||||
import ChatInputBar from '@/components/ChatInputBar.vue';
|
||||
import { RotateCcw, ChevronDown, ChevronUp } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'note-changed': [noteId: number];
|
||||
'task-changed': [];
|
||||
}>();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// SSE watcher — emit refresh events when tool calls succeed during streaming
|
||||
const processedCount = ref(0);
|
||||
|
||||
watch(
|
||||
() => chatStore.streamingToolCalls,
|
||||
(calls) => {
|
||||
for (let i = processedCount.value; i < calls.length; i++) {
|
||||
const tc = calls[i];
|
||||
if (tc.status !== 'success') continue;
|
||||
|
||||
// Note-editor refresh: any successful create_note/update_note
|
||||
if (['create_note', 'update_note'].includes(tc.function) && tc.result?.data?.id) {
|
||||
emit('note-changed', tc.result.data.id as number);
|
||||
}
|
||||
|
||||
// Task-panel refresh: milestone changes, or note changes where the note is a task (has status)
|
||||
const isTaskNoteChange =
|
||||
['create_note', 'update_note'].includes(tc.function) &&
|
||||
tc.result?.data?.status !== undefined;
|
||||
const isMilestoneChange = ['create_milestone', 'update_milestone'].includes(tc.function);
|
||||
if (isTaskNoteChange || isMilestoneChange) {
|
||||
emit('task-changed');
|
||||
}
|
||||
}
|
||||
processedCount.value = calls.length;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => chatStore.streaming,
|
||||
(s) => {
|
||||
if (!s) processedCount.value = 0;
|
||||
}
|
||||
);
|
||||
|
||||
type WidgetState = 'collapsed' | 'expanded';
|
||||
const widgetState = ref<WidgetState>('collapsed');
|
||||
|
||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
||||
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null);
|
||||
|
||||
const workspaceConvId = ref<number | null>(null);
|
||||
let isNewConv = false;
|
||||
|
||||
const historyOpen = ref(false);
|
||||
|
||||
const projectConversations = computed(() =>
|
||||
chatStore.conversations
|
||||
.filter((c) => c.rag_project_id === props.projectId)
|
||||
.slice()
|
||||
.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))
|
||||
);
|
||||
|
||||
async function pickConversation(convId: number) {
|
||||
historyOpen.value = false;
|
||||
if (convId === workspaceConvId.value) return;
|
||||
await chatStore.fetchConversation(convId);
|
||||
workspaceConvId.value = convId;
|
||||
// The picked conversation already exists on the server; not ours to clean up.
|
||||
isNewConv = false;
|
||||
localStorage.setItem(_storageKey(props.projectId), String(convId));
|
||||
}
|
||||
|
||||
function toggleHistory() {
|
||||
historyOpen.value = !historyOpen.value;
|
||||
}
|
||||
|
||||
function conversationLabel(c: { id: number; title?: string | null }): string {
|
||||
return (c.title && c.title.trim()) || `Conversation #${c.id}`;
|
||||
}
|
||||
|
||||
function _storageKey(pid: number) {
|
||||
return `workspace_conv_${pid}`;
|
||||
}
|
||||
|
||||
function expand() {
|
||||
widgetState.value = 'expanded';
|
||||
nextTick(() => chatPanelRef.value?.focus());
|
||||
}
|
||||
function collapse() {
|
||||
widgetState.value = 'collapsed';
|
||||
nextTick(() => inputBarRef.value?.focus());
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
const conv = await chatStore.createConversation();
|
||||
workspaceConvId.value = conv.id;
|
||||
isNewConv = true;
|
||||
localStorage.setItem(_storageKey(props.projectId), String(conv.id));
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
}
|
||||
|
||||
/** Auto-expand and send — used when user submits from collapsed input. */
|
||||
async function onCollapsedSubmit(payload: { content: string; contextNoteId?: number }) {
|
||||
widgetState.value = 'expanded';
|
||||
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, false);
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
if (widgetState.value === 'expanded') {
|
||||
chatPanelRef.value?.prefill(text);
|
||||
} else {
|
||||
inputBarRef.value?.prefill(text);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (chatStore.conversations.length === 0) {
|
||||
await chatStore.fetchConversations();
|
||||
}
|
||||
|
||||
const key = _storageKey(props.projectId);
|
||||
const storedId = localStorage.getItem(key);
|
||||
|
||||
if (storedId) {
|
||||
const existingId = Number(storedId);
|
||||
try {
|
||||
await chatStore.fetchConversation(existingId);
|
||||
workspaceConvId.value = existingId;
|
||||
isNewConv = false;
|
||||
chatStore.reconnectIfGenerating(existingId);
|
||||
} catch {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (workspaceConvId.value === null) {
|
||||
const conv = await chatStore.createConversation();
|
||||
workspaceConvId.value = conv.id;
|
||||
isNewConv = true;
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
localStorage.setItem(key, String(conv.id));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
if (workspaceConvId.value !== null && isNewConv) {
|
||||
const id = workspaceConvId.value;
|
||||
const conv = chatStore.conversations.find((c) => c.id === id);
|
||||
if (conv && conv.message_count === 0) {
|
||||
await chatStore.deleteConversation(id);
|
||||
localStorage.removeItem(_storageKey(props.projectId));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ prefill });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Bottom-anchored chat dock: collapsed (input only) or expanded (full panel) -->
|
||||
<div
|
||||
class="ws-chat-widget"
|
||||
:class="{ 'ws-chat-widget--expanded': widgetState === 'expanded' }"
|
||||
>
|
||||
<!-- Header (expanded only) -->
|
||||
<div v-if="widgetState === 'expanded'" class="ws-chat-header">
|
||||
<span class="ws-chat-title">Project chat</span>
|
||||
<div class="ws-chat-header-actions">
|
||||
<div class="ws-chat-history-wrap">
|
||||
<button
|
||||
class="btn-icon-sm"
|
||||
:class="{ active: historyOpen }"
|
||||
title="History"
|
||||
@click="toggleHistory"
|
||||
>
|
||||
<ChevronDown :size="16" />
|
||||
</button>
|
||||
<div v-if="historyOpen" class="ws-chat-history-menu">
|
||||
<div v-if="projectConversations.length === 0" class="ws-chat-history-empty">
|
||||
No past conversations
|
||||
</div>
|
||||
<button
|
||||
v-for="c in projectConversations"
|
||||
:key="c.id"
|
||||
class="ws-chat-history-item"
|
||||
:class="{ current: c.id === workspaceConvId }"
|
||||
@click="pickConversation(c.id)"
|
||||
>
|
||||
<span class="ws-chat-history-title">{{ conversationLabel(c) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-sm" title="Restart conversation" @click="restart">
|
||||
<RotateCcw :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" title="Collapse" @click="collapse">
|
||||
<ChevronDown :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded body: full ChatPanel (includes its own input bar) -->
|
||||
<div v-if="widgetState === 'expanded'" class="ws-chat-body">
|
||||
<ChatPanel
|
||||
ref="chatPanelRef"
|
||||
variant="full"
|
||||
:projectId="projectId"
|
||||
placeholder="Message the agent…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Collapsed: standalone input bar with expand affordance -->
|
||||
<div v-else class="ws-chat-collapsed">
|
||||
<button class="ws-chat-expand-btn" title="Expand chat" @click="expand">
|
||||
<ChevronUp :size="16" />
|
||||
</button>
|
||||
<div class="ws-chat-input-row">
|
||||
<ChatInputBar
|
||||
ref="inputBarRef"
|
||||
placeholder="Message the agent…"
|
||||
@submit="onCollapsedSubmit"
|
||||
@abort="chatStore.cancelGeneration()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ws-chat-widget {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: var(--page-max-width);
|
||||
margin-inline: auto;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.35), 0 -1px 0 rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
.ws-chat-widget--expanded {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.ws-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ws-chat-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.ws-chat-header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-icon-sm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-icon-sm:hover:not(:disabled) {
|
||||
color: var(--color-text);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.btn-icon-sm:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-icon-sm.active {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.ws-chat-history-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.ws-chat-history-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 220px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
z-index: 30;
|
||||
padding: 4px;
|
||||
}
|
||||
.ws-chat-history-empty {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.ws-chat-history-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ws-chat-history-item:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.ws-chat-history-item.current {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ws-chat-history-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ws-chat-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ws-chat-body :deep(.chat-body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ws-chat-collapsed {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.ws-chat-expand-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-right: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ws-chat-expand-btn:hover {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.ws-chat-input-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import WordCount from "@/components/WordCount.vue";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
@@ -316,7 +317,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
{{ creatingNote ? '…' : '+' }}
|
||||
</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote">✕</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rail-title">Notes</span>
|
||||
@@ -332,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
type="search"
|
||||
aria-label="Search notes"
|
||||
/>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''">✕</button>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div v-if="listLoading" class="rail-state">Loading…</div>
|
||||
@@ -360,10 +361,10 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)">✕</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -416,7 +417,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
||||
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()">✕</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-row">
|
||||
@@ -429,7 +430,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||
</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []">✕</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||
@@ -457,12 +458,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
/* ── Left rail ── */
|
||||
.note-rail {
|
||||
width: 155px;
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
@@ -479,7 +480,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -532,7 +533,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
padding: 1rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Note list */
|
||||
@@ -551,13 +551,13 @@ defineExpose({ reload: loadProjectNotes });
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
gap: 0.15rem;
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.note-row.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
border-left-color: var(--color-primary);
|
||||
border-right-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.note-row-main {
|
||||
@@ -630,18 +630,20 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
}
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
.btn-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-delete:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-confirm-delete {
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-delete {
|
||||
@@ -707,7 +709,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Editor UI */
|
||||
@@ -715,7 +716,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -730,20 +730,22 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
||||
|
||||
/* Moss action-primary per Hybrid */
|
||||
.btn-save {
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.note-title-row {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0.9rem 1.1rem 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -751,19 +753,24 @@ defineExpose({ reload: loadProjectNotes });
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text);
|
||||
padding: 0;
|
||||
font-family: 'Fraunces', serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.note-title-input:focus { outline: none; }
|
||||
.note-title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
@@ -789,7 +796,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -824,7 +830,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.toolbar-row {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -834,7 +839,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -842,7 +846,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
@@ -298,12 +299,12 @@ defineExpose({ reload: loadAll });
|
||||
</span>
|
||||
<template v-if="deleteConfirmPending">
|
||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask">✕</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail">✕</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
||||
@@ -373,7 +374,7 @@ defineExpose({ reload: loadAll });
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-add {
|
||||
@@ -432,7 +433,7 @@ defineExpose({ reload: loadAll });
|
||||
.ms-group-header:hover { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
|
||||
|
||||
.ms-chevron { font-size: 0.6rem; color: var(--color-text-muted); width: 0.8rem; }
|
||||
.ms-name { flex: 1; font-weight: 600; font-size: 0.8rem; }
|
||||
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
|
||||
.ms-count { font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); border-radius: 10px; padding: 0 0.4rem; }
|
||||
|
||||
.ms-status {
|
||||
@@ -494,7 +495,7 @@ defineExpose({ reload: loadAll });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); font-style: italic; }
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); }
|
||||
|
||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
|
||||
|
||||
@@ -522,7 +523,7 @@ defineExpose({ reload: loadAll });
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: none;
|
||||
@@ -575,19 +576,21 @@ defineExpose({ reload: loadAll });
|
||||
border-radius: 3px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.btn-delete-task:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-delete-task:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-delete-confirm {
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.25rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-delete-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-delete-cancel {
|
||||
@@ -603,7 +606,7 @@ defineExpose({ reload: loadAll });
|
||||
.detail-title {
|
||||
padding: 0.75rem 0.75rem 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
@@ -672,7 +675,7 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
.task-due.overdue {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Close detail button */
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
/**
|
||||
* Idle-preload the Silero VAD ONNX model and its companion assets.
|
||||
*
|
||||
* `@ricky0123/vad-web` pulls ~2MB of ONNX model + ~5MB of onnxruntime-web
|
||||
* WASM the first time `MicVAD.new()` runs. Calling `defaultModelFetcher`
|
||||
* during page idle warms the browser cache so the first mic click feels
|
||||
* instant. If the preload fails, VAD simply loads lazily on first click.
|
||||
*/
|
||||
const ready = ref(false)
|
||||
let loading = false
|
||||
|
||||
export function useOnnxPreloader() {
|
||||
async function preload() {
|
||||
if (ready.value || loading) return
|
||||
loading = true
|
||||
try {
|
||||
const { defaultModelFetcher } = await import('@ricky0123/vad-web')
|
||||
// Asset lives at site root because vite-plugin-static-copy drops it
|
||||
// there. Match whatever path MicVAD will use at call time (see useVad).
|
||||
await defaultModelFetcher('/silero_vad_v5.onnx')
|
||||
ready.value = true
|
||||
} catch {
|
||||
// Non-fatal — MicVAD will load the model itself on first use.
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePreload() {
|
||||
if (ready.value) return
|
||||
if (typeof window === 'undefined') return
|
||||
if ('requestIdleCallback' in window) {
|
||||
(window as Window & { requestIdleCallback: (cb: () => void, opts: { timeout: number }) => void })
|
||||
.requestIdleCallback(() => void preload(), { timeout: 5000 })
|
||||
} else {
|
||||
setTimeout(() => void preload(), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
return { ready: readonly(ready), schedulePreload }
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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()
|
||||
// Some browsers start AudioContext in "suspended" state — resume so
|
||||
// getByteFrequencyData returns real values instead of all zeros.
|
||||
audioCtx.resume().catch(() => {})
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { MicVAD } from '@ricky0123/vad-web'
|
||||
|
||||
export interface UseVadOptions {
|
||||
onSpeechEnd: () => void
|
||||
onNoSpeech: () => void
|
||||
onVadError?: (message: string) => void
|
||||
graceMs?: number
|
||||
minRecordingMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Silero VAD-based speech detector.
|
||||
*
|
||||
* Replaces amplitude-based silence detection. Uses the Silero VAD v5 ONNX
|
||||
* model via `@ricky0123/vad-web`. The package ships the model + audio
|
||||
* worklet, and depends on `onnxruntime-web`'s WASM. Vite is configured
|
||||
* (see `vite.config.ts`) to serve these assets from the site root, which
|
||||
* is why we pass `baseAssetPath: "/"` and `onnxWASMBasePath: "/"`.
|
||||
*
|
||||
* `amplitude` is computed separately from a Web Audio API analyser node
|
||||
* on the same stream — it drives the mic pulse animation and is not part
|
||||
* of the stop decision. VAD's `onSpeechEnd` is the stop trigger.
|
||||
*
|
||||
* If VAD never detects speech during a session, calling `stopAndCheck()`
|
||||
* fires `onNoSpeech` instead of treating the recording as transcribable.
|
||||
*/
|
||||
export function useVad(options: UseVadOptions) {
|
||||
const {
|
||||
onSpeechEnd,
|
||||
onNoSpeech,
|
||||
graceMs = 1500,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
const speaking = ref(false)
|
||||
const amplitude = ref(0)
|
||||
const loaded = ref(false)
|
||||
|
||||
let micVad: MicVAD | null = null
|
||||
let audioCtx: AudioContext | null = null
|
||||
let analyserInterval: ReturnType<typeof setInterval> | null = null
|
||||
let speechDetected = false
|
||||
let speechStartedAt = 0
|
||||
let recordingStartedAt = 0
|
||||
let stopped = false
|
||||
|
||||
async function start(stream: MediaStream): Promise<void> {
|
||||
await stop()
|
||||
stopped = false
|
||||
speechDetected = false
|
||||
speechStartedAt = 0
|
||||
recordingStartedAt = Date.now()
|
||||
|
||||
// Visual-only amplitude signal. This does NOT drive the stop decision.
|
||||
audioCtx = new AudioContext()
|
||||
await audioCtx.resume().catch(() => {})
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 1024
|
||||
source.connect(analyser)
|
||||
const samples = new Float32Array(analyser.fftSize)
|
||||
|
||||
analyserInterval = setInterval(() => {
|
||||
analyser.getFloatTimeDomainData(samples)
|
||||
let sumSq = 0
|
||||
for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]
|
||||
const rms = Math.sqrt(sumSq / samples.length)
|
||||
amplitude.value = Math.min(rms * 5, 1)
|
||||
}, 100)
|
||||
|
||||
try {
|
||||
const { MicVAD } = await import('@ricky0123/vad-web')
|
||||
micVad = await MicVAD.new({
|
||||
model: 'v5',
|
||||
baseAssetPath: '/',
|
||||
onnxWASMBasePath: '/',
|
||||
// MicVAD manages its own audio graph, so we hand it the same stream
|
||||
// the MediaRecorder is using. It won't consume or alter the stream.
|
||||
getStream: async () => stream,
|
||||
onSpeechStart: () => {
|
||||
speaking.value = true
|
||||
if (!speechDetected) {
|
||||
speechDetected = true
|
||||
speechStartedAt = Date.now()
|
||||
}
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
speaking.value = false
|
||||
if (stopped) return
|
||||
const now = Date.now()
|
||||
const totalElapsed = now - recordingStartedAt
|
||||
const sinceSpeechStart = speechStartedAt > 0 ? now - speechStartedAt : 0
|
||||
if (totalElapsed >= minRecordingMs && sinceSpeechStart >= graceMs) {
|
||||
stopped = true
|
||||
onSpeechEnd()
|
||||
}
|
||||
},
|
||||
onVADMisfire: () => {
|
||||
// Speech-like blip that was too short to count. Reset the local flag
|
||||
// so a true "no speech" session still hits onNoSpeech.
|
||||
speaking.value = false
|
||||
},
|
||||
})
|
||||
await micVad.start()
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
console.error('VAD init failed:', e)
|
||||
options.onVadError?.(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(): Promise<{ hadSpeech: boolean }> {
|
||||
const had = speechDetected
|
||||
stopped = true
|
||||
speaking.value = false
|
||||
amplitude.value = 0
|
||||
|
||||
if (analyserInterval !== null) {
|
||||
clearInterval(analyserInterval)
|
||||
analyserInterval = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
await audioCtx.close().catch(() => {})
|
||||
audioCtx = null
|
||||
}
|
||||
if (micVad) {
|
||||
try {
|
||||
await micVad.pause()
|
||||
await micVad.destroy()
|
||||
} catch {
|
||||
// Already destroyed or failed — nothing to clean up further.
|
||||
}
|
||||
micVad = null
|
||||
}
|
||||
return { hadSpeech: had }
|
||||
}
|
||||
|
||||
async function stopAndCheck(): Promise<void> {
|
||||
const { hadSpeech } = await stop()
|
||||
if (!hadSpeech) {
|
||||
onNoSpeech()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
speaking: readonly(speaking),
|
||||
amplitude: readonly(amplitude),
|
||||
loaded: readonly(loaded),
|
||||
start,
|
||||
stop,
|
||||
stopAndCheck,
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,16 @@ const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
// Root lands on the journal. To revert to Knowledge as home, change
|
||||
// the redirect target below to "/knowledge" (Knowledge stays a real
|
||||
// route at /knowledge, so the swap is a one-line edit).
|
||||
path: "/",
|
||||
name: "knowledge",
|
||||
component: () => import("@/views/KnowledgeView.vue"),
|
||||
redirect: "/journal",
|
||||
},
|
||||
{
|
||||
path: "/knowledge",
|
||||
redirect: "/",
|
||||
name: "knowledge",
|
||||
component: () => import("@/views/KnowledgeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
@@ -45,8 +48,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: "/notes",
|
||||
name: "notes",
|
||||
component: () => import("@/views/NotesListView.vue"),
|
||||
redirect: "/knowledge",
|
||||
},
|
||||
{
|
||||
path: "/notes/new",
|
||||
@@ -85,8 +87,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
name: "tasks",
|
||||
component: () => import("@/views/TasksListView.vue"),
|
||||
redirect: "/",
|
||||
},
|
||||
{
|
||||
path: "/tasks/new",
|
||||
@@ -119,14 +120,9 @@ const router = createRouter({
|
||||
component: () => import("@/views/CalendarView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/briefing",
|
||||
name: "briefing",
|
||||
component: () => import("@/views/BriefingView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/news",
|
||||
name: "news",
|
||||
component: () => import("@/views/NewsView.vue"),
|
||||
path: "/journal",
|
||||
name: "journal",
|
||||
component: () => import("@/views/JournalView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import ChatPanel from '@/components/ChatPanel.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
getBriefingConfig,
|
||||
getBriefingConversations,
|
||||
getBriefingToday,
|
||||
triggerBriefingSlot,
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
type BriefingConversation,
|
||||
} from '@/api/client'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
interface WeatherData {
|
||||
location: string
|
||||
fetched_at: string
|
||||
current_temp: number
|
||||
condition: string
|
||||
today_high: number | null
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; precip_mm: number | null; windspeed_max: number }[]
|
||||
}
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// Setup wizard
|
||||
const showWizard = ref(false)
|
||||
const wizardChecked = ref(false)
|
||||
|
||||
async function checkSetup() {
|
||||
const config = await getBriefingConfig()
|
||||
if (!config.enabled) showWizard.value = true
|
||||
wizardChecked.value = true
|
||||
}
|
||||
|
||||
async function onWizardDone() {
|
||||
showWizard.value = false
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
// Conversations list for the dropdown
|
||||
const conversations = ref<BriefingConversation[]>([])
|
||||
const selectedConvId = ref<number | null>(null)
|
||||
const todayConvId = ref<number | null>(null)
|
||||
|
||||
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||
|
||||
// Weather panel (left column)
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
const tempUnit = ref<string>('C')
|
||||
|
||||
async function loadWeather() {
|
||||
try {
|
||||
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// News panel (right column)
|
||||
const newsItems = ref<NewsItem[]>([])
|
||||
|
||||
async function loadNews() {
|
||||
try {
|
||||
const data = await getNewsItems({ days: 2, limit: 40 })
|
||||
newsItems.value = data.items
|
||||
// Seed reactions from API response
|
||||
for (const item of data.items) {
|
||||
if (reactions.value[item.id] === undefined) {
|
||||
reactions.value[item.id] = item.reaction
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [convList, today] = await Promise.all([
|
||||
getBriefingConversations(),
|
||||
getBriefingToday().catch(() => null),
|
||||
loadWeather(),
|
||||
loadNews(),
|
||||
])
|
||||
conversations.value = convList
|
||||
if (today) {
|
||||
todayConvId.value = today.id
|
||||
if (!convList.find((c) => c.id === today.id)) {
|
||||
conversations.value = [
|
||||
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
|
||||
...convList,
|
||||
]
|
||||
}
|
||||
selectedConvId.value = today.id
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedConvId, async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
await chatStore.fetchConversation(id)
|
||||
} catch {
|
||||
// Historical conversation unavailable — do nothing
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
await chatStore.reconnectIfGenerating(todayConvId.value)
|
||||
}
|
||||
|
||||
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
|
||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
||||
|
||||
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
|
||||
const current = reactions.value[itemId]
|
||||
reactions.value[itemId] = current === reaction ? null : reaction
|
||||
try {
|
||||
if (current === reaction) {
|
||||
await deleteRssReaction(itemId)
|
||||
} else {
|
||||
await postRssReaction(itemId, reaction)
|
||||
}
|
||||
} catch {
|
||||
reactions.value[itemId] = current ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
||||
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
||||
if (diffH < 48) return 'Yesterday'
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// Manual trigger
|
||||
const triggering = ref(false)
|
||||
async function triggerNow() {
|
||||
triggering.value = true
|
||||
try {
|
||||
await triggerBriefingSlot('compilation')
|
||||
// Guard: user may have navigated away during the long compilation
|
||||
if (_mounted) await loadAll()
|
||||
} finally {
|
||||
if (_mounted) triggering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown label
|
||||
function convLabel(c: BriefingConversation): string {
|
||||
if (c.id === todayConvId.value) return 'Today'
|
||||
if (c.briefing_date) {
|
||||
const d = new Date(c.briefing_date)
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
return c.title || 'Briefing'
|
||||
}
|
||||
|
||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||
async function _backgroundRefreshMessages() {
|
||||
try {
|
||||
const today = await getBriefingToday()
|
||||
if (!today) return
|
||||
if (_mounted && isToday.value && chatStore.currentConversation?.id === todayConvId.value) {
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
await loadNews()
|
||||
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
||||
}
|
||||
|
||||
useBackgroundRefresh(
|
||||
_backgroundRefreshMessages,
|
||||
60_000,
|
||||
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
|
||||
)
|
||||
|
||||
let _mounted = true
|
||||
onUnmounted(() => { _mounted = false })
|
||||
|
||||
onMounted(async () => {
|
||||
await checkSetup()
|
||||
if (!showWizard.value) await loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="briefing-root">
|
||||
<!-- Setup wizard overlay -->
|
||||
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
|
||||
|
||||
<!-- Main view -->
|
||||
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
|
||||
<!-- Header spans all columns -->
|
||||
<header class="briefing-header">
|
||||
<div class="briefing-header-left">
|
||||
<h1 class="briefing-title">Briefing</h1>
|
||||
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
|
||||
</div>
|
||||
<div class="briefing-header-right">
|
||||
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
|
||||
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn-trigger"
|
||||
@click="triggerNow"
|
||||
:disabled="triggering"
|
||||
title="Manually trigger morning briefing now"
|
||||
>{{ triggering ? '…' : 'Refresh' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Left column: Weather -->
|
||||
<div class="briefing-left">
|
||||
<div class="panel-label">Weather</div>
|
||||
<template v-if="weatherData.length">
|
||||
<WeatherCard
|
||||
v-for="loc in weatherData"
|
||||
:key="(loc as WeatherData).location"
|
||||
:weather="loc"
|
||||
:temp-unit="tempUnit"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="panel-empty">No weather configured</div>
|
||||
</div>
|
||||
|
||||
<!-- Center column: Chat -->
|
||||
<div class="briefing-center">
|
||||
<ChatPanel
|
||||
variant="full"
|
||||
briefingMode
|
||||
:readOnly="!isToday"
|
||||
placeholder="Reply to your briefing…"
|
||||
class="briefing-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right column: News -->
|
||||
<div class="briefing-right">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Today's News</div>
|
||||
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
|
||||
</div>
|
||||
<div v-if="!newsItems.length" class="panel-empty">No articles in the last 2 days</div>
|
||||
<div
|
||||
v-for="item in newsItems"
|
||||
:key="item.id"
|
||||
class="news-card"
|
||||
>
|
||||
<div class="news-card-meta">
|
||||
<span class="news-source">{{ item.source }}</span>
|
||||
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="item.url"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="news-title"
|
||||
>{{ item.title }}</a>
|
||||
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
|
||||
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
||||
<div class="news-reactions">
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'up' }"
|
||||
@click="handleReaction(item.id, 'up')"
|
||||
title="Interested"
|
||||
>👍</button>
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'down' }"
|
||||
@click="handleReaction(item.id, 'down')"
|
||||
title="Not interested"
|
||||
>👎</button>
|
||||
<button
|
||||
v-if="isToday && todayConvId"
|
||||
class="reaction-btn discuss-btn"
|
||||
@click="discussArticle(item)"
|
||||
title="Discuss in briefing chat"
|
||||
>💬</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.briefing-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-header {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.briefing-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.briefing-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.briefing-today-badge {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.briefing-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.briefing-conv-select {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-trigger {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-trigger:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
|
||||
|
||||
.briefing-left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.briefing-left :deep(.weather-card) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ─── Center column (Chat) ───────────────────────────────────────────────── */
|
||||
|
||||
.briefing-center {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ─── Right column (News) ────────────────────────────────────────────────── */
|
||||
|
||||
.briefing-right {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
border-left: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-count {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.news-source {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.35;
|
||||
text-decoration: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
|
||||
.news-snippet {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-reactions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.reaction-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.reaction-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.briefing-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
}
|
||||
.briefing-header {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.briefing-left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
max-height: 220px;
|
||||
}
|
||||
.briefing-center {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
.briefing-right {
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ 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);
|
||||
@@ -333,9 +334,7 @@ const upcomingGrouped = computed(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="ev.location" class="upcoming-card-meta">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||
</svg>
|
||||
<MapPin :size="16" style="flex-shrink:0" />
|
||||
{{ ev.location }}
|
||||
</div>
|
||||
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
|
||||
@@ -367,9 +366,7 @@ const upcomingGrouped = computed(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="popover.location" class="popover-meta">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;margin-top:1px">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||
</svg>
|
||||
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
|
||||
{{ popover.location }}
|
||||
</div>
|
||||
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
|
||||
@@ -429,23 +426,24 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.cal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
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: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-new-event:hover { opacity: 0.88; }
|
||||
.btn-new-event:hover { background: var(--color-action-primary-hover); }
|
||||
|
||||
.fc-wrapper {
|
||||
background: var(--color-surface);
|
||||
@@ -461,8 +459,12 @@ const upcomingGrouped = computed(() => {
|
||||
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: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
@@ -482,8 +484,8 @@ const upcomingGrouped = computed(() => {
|
||||
}
|
||||
:deep(.fc-button:hover),
|
||||
:deep(.fc-button-active) {
|
||||
background: var(--color-primary, #6366f1) !important;
|
||||
border-color: var(--color-primary, #6366f1) !important;
|
||||
background: var(--color-primary) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
:deep(.fc-button:focus) { box-shadow: none !important; }
|
||||
@@ -494,7 +496,7 @@ const upcomingGrouped = computed(() => {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
:deep(.fc-daygrid-day.fc-day-today) {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
:deep(.fc-event) {
|
||||
border-radius: 4px;
|
||||
@@ -505,7 +507,7 @@ const upcomingGrouped = computed(() => {
|
||||
}
|
||||
:deep(.fc-event-main) { color: #fff; }
|
||||
:deep(.fc-event:not([style*="background"])) {
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
}
|
||||
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
|
||||
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
|
||||
@@ -533,7 +535,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.picker-year-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
@@ -574,9 +576,9 @@ const upcomingGrouped = computed(() => {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.picker-month.active {
|
||||
background: rgba(99,102,241,0.22);
|
||||
color: var(--color-primary, #818cf8);
|
||||
font-weight: 700;
|
||||
background: rgba(91, 74, 138,0.22);
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
||||
@@ -586,7 +588,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.upcoming-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -602,7 +604,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.upcoming-day-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -634,7 +636,7 @@ const upcomingGrouped = computed(() => {
|
||||
.upcoming-card-accent {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--ev-color, #6366f1);
|
||||
background: var(--ev-color, #5B4A8A);
|
||||
}
|
||||
|
||||
.upcoming-card-body {
|
||||
@@ -645,7 +647,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.upcoming-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -691,7 +693,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.popover-accent {
|
||||
height: 4px;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
@@ -700,7 +702,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.popover-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
@@ -744,13 +746,13 @@ const upcomingGrouped = computed(() => {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.popover-btn:hover { opacity: 0.85; }
|
||||
.popover-btn--edit {
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.popover-btn--close {
|
||||
|
||||
+315
-151
@@ -2,7 +2,9 @@
|
||||
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { apiGet } from "@/api/client";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import { MoreVertical, Menu, Paperclip, Trash2 } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -10,6 +12,49 @@ const store = useChatStore();
|
||||
|
||||
const summarizing = ref(false);
|
||||
const sidebarOpen = ref(false);
|
||||
const convSearchQuery = ref("");
|
||||
const headerKebabOpen = ref(false);
|
||||
const sidebarKebabOpen = ref(false);
|
||||
|
||||
// ── RAG scope chip ────────────────────────────────────────────────────────────
|
||||
const projects = ref<{ id: number; title: string }[]>([]);
|
||||
const scopeDropdownOpen = ref(false);
|
||||
const scopePulse = ref(false);
|
||||
|
||||
const scopeLabel = computed(() => {
|
||||
const id = store.ragProjectId;
|
||||
if (id === -1) return "All notes";
|
||||
if (id === null) return "Orphan notes";
|
||||
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`;
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const data = await apiGet<{ projects: { id: number; title: string }[] }>(
|
||||
"/api/projects?status=active"
|
||||
);
|
||||
projects.value = data.projects ?? [];
|
||||
} catch {
|
||||
projects.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function onScopeSelect(value: number | null) {
|
||||
scopeDropdownOpen.value = false;
|
||||
const id = store.currentConversation?.id;
|
||||
if (!id) return;
|
||||
await store.updateRagScope(id, value);
|
||||
scopePulse.value = true;
|
||||
setTimeout(() => { scopePulse.value = false; }, 600);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.ragProjectId,
|
||||
() => {
|
||||
scopePulse.value = true;
|
||||
setTimeout(() => { scopePulse.value = false; }, 600);
|
||||
}
|
||||
);
|
||||
|
||||
let prevConvId: number | null = null;
|
||||
|
||||
@@ -46,7 +91,12 @@ const groupedConversations = computed((): ConvGroup[] => {
|
||||
const buckets: Record<string, typeof store.conversations> = {};
|
||||
const order: string[] = [];
|
||||
|
||||
for (const conv of store.conversations) {
|
||||
const q = convSearchQuery.value.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? store.conversations.filter((c) => (c.title || "").toLowerCase().includes(q))
|
||||
: store.conversations;
|
||||
|
||||
for (const conv of filtered) {
|
||||
const d = new Date(conv.updated_at);
|
||||
let key: string;
|
||||
if (d >= startOfToday) {
|
||||
@@ -69,6 +119,8 @@ const groupedConversations = computed((): ConvGroup[] => {
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", onGlobalKeydown);
|
||||
document.addEventListener("mousedown", onDocumentMousedown);
|
||||
loadProjects();
|
||||
await store.fetchConversations();
|
||||
if (convId.value) {
|
||||
if (store.currentConversation?.id !== convId.value) {
|
||||
@@ -187,36 +239,35 @@ async function handleSummarize() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Research modal ────────────────────────────────────────────────────────────
|
||||
const showResearchModal = ref(false);
|
||||
const researchTopic = ref("");
|
||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
||||
|
||||
function toggleResearchModal() {
|
||||
showResearchModal.value = !showResearchModal.value;
|
||||
if (showResearchModal.value) {
|
||||
researchTopic.value = "";
|
||||
nextTick(() => {
|
||||
const el = document.querySelector(".research-topic-input") as HTMLInputElement;
|
||||
el?.focus();
|
||||
});
|
||||
}
|
||||
const contextCount = computed(() => chatPanelRef.value?.contextCount ?? 0);
|
||||
const contextSidebarOpen = computed(() => chatPanelRef.value?.sidebarOpen ?? false);
|
||||
function toggleContextSidebar() {
|
||||
chatPanelRef.value?.toggleContextSidebar();
|
||||
}
|
||||
|
||||
function submitResearch() {
|
||||
const topic = researchTopic.value.trim();
|
||||
if (!topic) return;
|
||||
showResearchModal.value = false;
|
||||
researchTopic.value = "";
|
||||
// Prefill sends via ChatPanel — user sees it in the input, then it auto-submits
|
||||
chatPanelRef.value?.send(`Research: ${topic}`);
|
||||
// ── Kebab dismissal ───────────────────────────────────────────────────────────
|
||||
function onDocumentMousedown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (headerKebabOpen.value && !target?.closest(".header-kebab-wrapper")) {
|
||||
headerKebabOpen.value = false;
|
||||
}
|
||||
if (sidebarKebabOpen.value && !target?.closest(".sidebar-kebab-wrapper")) {
|
||||
sidebarKebabOpen.value = false;
|
||||
}
|
||||
if (scopeDropdownOpen.value && !target?.closest(".scope-chip-wrapper")) {
|
||||
scopeDropdownOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────────
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
if (showResearchModal.value) {
|
||||
showResearchModal.value = false;
|
||||
if (headerKebabOpen.value || sidebarKebabOpen.value || scopeDropdownOpen.value) {
|
||||
headerKebabOpen.value = false;
|
||||
sidebarKebabOpen.value = false;
|
||||
scopeDropdownOpen.value = false;
|
||||
return;
|
||||
}
|
||||
if (sidebarOpen.value) {
|
||||
@@ -228,6 +279,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", onGlobalKeydown);
|
||||
document.removeEventListener("mousedown", onDocumentMousedown);
|
||||
if (prevConvId) {
|
||||
const conv = store.conversations.find((c) => c.id === prevConvId);
|
||||
if (conv && conv.message_count === 0) {
|
||||
@@ -246,13 +298,33 @@ onUnmounted(() => {
|
||||
></div>
|
||||
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
|
||||
<div class="sidebar-top-bar">
|
||||
<button class="btn-new-conv" @click="newConversation">+ New Chat</button>
|
||||
<button
|
||||
class="btn-select-mode"
|
||||
:class="{ active: selectMode }"
|
||||
@click="toggleSelectMode"
|
||||
title="Select conversations"
|
||||
>Select</button>
|
||||
<button class="btn-new-conv btn-new-conv--full" @click="newConversation">+ New Chat</button>
|
||||
<div class="sidebar-search-row">
|
||||
<input
|
||||
v-model="convSearchQuery"
|
||||
class="conv-search-input"
|
||||
type="search"
|
||||
placeholder="Search conversations…"
|
||||
aria-label="Search conversations"
|
||||
/>
|
||||
<div class="sidebar-kebab-wrapper">
|
||||
<button
|
||||
class="btn-kebab"
|
||||
:class="{ active: sidebarKebabOpen }"
|
||||
@click="sidebarKebabOpen = !sidebarKebabOpen"
|
||||
aria-label="List actions"
|
||||
title="List actions"
|
||||
>
|
||||
<MoreVertical :size="16" />
|
||||
</button>
|
||||
<div v-if="sidebarKebabOpen" class="kebab-menu">
|
||||
<button
|
||||
class="kebab-item"
|
||||
@click="sidebarKebabOpen = false; toggleSelectMode()"
|
||||
>{{ selectMode ? "Exit select mode" : "Select conversations" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectMode" class="bulk-bar">
|
||||
@@ -266,6 +338,7 @@ onUnmounted(() => {
|
||||
:disabled="bulkDeleting"
|
||||
@click="bulkDelete"
|
||||
>
|
||||
<Trash2 :size="16" />
|
||||
{{ bulkDeleting ? '...' : bulkConfirm ? `Delete ${selectedIds.size}?` : `Delete (${selectedIds.size})` }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -304,6 +377,10 @@ onUnmounted(() => {
|
||||
<p v-if="!store.conversations.length" class="empty-msg">
|
||||
No conversations yet
|
||||
</p>
|
||||
<p
|
||||
v-else-if="convSearchQuery && !groupedConversations.length"
|
||||
class="empty-msg"
|
||||
>No matches for “{{ convSearchQuery }}”</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -311,48 +388,70 @@ onUnmounted(() => {
|
||||
<template v-if="store.currentConversation">
|
||||
<div class="chat-header">
|
||||
<button class="btn-sidebar-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<Menu :size="24" />
|
||||
</button>
|
||||
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
|
||||
|
||||
<!-- Research modal trigger -->
|
||||
<div class="research-wrapper">
|
||||
<div class="scope-chip-wrapper">
|
||||
<button
|
||||
class="btn-attach"
|
||||
@click="toggleResearchModal"
|
||||
:disabled="store.streaming || !store.chatReady"
|
||||
title="Research a topic"
|
||||
class="scope-chip"
|
||||
:class="{ pulse: scopePulse }"
|
||||
@click="scopeDropdownOpen = !scopeDropdownOpen"
|
||||
title="Change RAG scope"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<span class="scope-dot">⊙</span> {{ scopeLabel }}
|
||||
</button>
|
||||
<div v-if="showResearchModal" class="research-modal">
|
||||
<div class="research-modal-header">Research topic</div>
|
||||
<input
|
||||
class="research-topic-input"
|
||||
v-model="researchTopic"
|
||||
placeholder="e.g. quantum computing"
|
||||
@keydown.enter="submitResearch"
|
||||
@keydown.escape="showResearchModal = false"
|
||||
/>
|
||||
<div class="research-modal-actions">
|
||||
<button class="btn-research-cancel" @click="showResearchModal = false">Cancel</button>
|
||||
<button class="btn-research-go" @click="submitResearch" :disabled="!researchTopic.trim()">Go</button>
|
||||
</div>
|
||||
<div v-if="scopeDropdownOpen" class="scope-dropdown">
|
||||
<button
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === null }"
|
||||
@click="onScopeSelect(null)"
|
||||
>Orphan notes only</button>
|
||||
<button
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === p.id }"
|
||||
@click="onScopeSelect(p.id)"
|
||||
>{{ p.title }}</button>
|
||||
<button
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === -1 }"
|
||||
@click="onScopeSelect(-1)"
|
||||
>All notes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="store.currentConversation.messages.length"
|
||||
class="btn-summarize"
|
||||
@click="handleSummarize"
|
||||
:disabled="summarizing || store.streaming"
|
||||
>{{ summarizing ? "Summarizing..." : "Summarize as Note" }}</button>
|
||||
v-if="contextCount > 0"
|
||||
class="btn-context-toggle"
|
||||
:class="{ active: contextSidebarOpen }"
|
||||
@click="toggleContextSidebar"
|
||||
:title="contextSidebarOpen ? 'Hide context panel' : 'Show context panel'"
|
||||
>
|
||||
<Paperclip class="context-icon" :size="16" />
|
||||
<span class="context-label">Context</span>
|
||||
<span class="context-count-badge">{{ contextCount }}</span>
|
||||
</button>
|
||||
|
||||
<div class="header-kebab-wrapper">
|
||||
<button
|
||||
class="btn-kebab"
|
||||
:class="{ active: headerKebabOpen }"
|
||||
@click="headerKebabOpen = !headerKebabOpen"
|
||||
aria-label="Conversation actions"
|
||||
title="Conversation actions"
|
||||
>
|
||||
<MoreVertical :size="16" />
|
||||
</button>
|
||||
<div v-if="headerKebabOpen" class="kebab-menu kebab-menu--right">
|
||||
<button
|
||||
class="kebab-item"
|
||||
:disabled="!store.currentConversation.messages.length || summarizing || store.streaming"
|
||||
@click="headerKebabOpen = false; handleSummarize()"
|
||||
>{{ summarizing ? "Summarizing…" : "Summarize as Note" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatPanel
|
||||
@@ -365,11 +464,7 @@ onUnmounted(() => {
|
||||
|
||||
<div v-else class="no-conversation">
|
||||
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<Menu :size="24" />
|
||||
</button>
|
||||
<p>Select a conversation or start a new chat.</p>
|
||||
<button class="btn-new-conv" @click="newConversation">
|
||||
@@ -397,12 +492,12 @@ onUnmounted(() => {
|
||||
|
||||
.sidebar-top-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-new-conv {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
@@ -412,23 +507,31 @@ onUnmounted(() => {
|
||||
font-size: 0.9rem;
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-new-conv--full { width: 100%; }
|
||||
.btn-new-conv:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
|
||||
box-shadow: 0 0 14px rgba(91, 74, 138, 0.35);
|
||||
}
|
||||
|
||||
.btn-select-mode {
|
||||
background: none;
|
||||
.sidebar-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.conv-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-select-mode:hover,
|
||||
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.conv-search-input:focus { border-color: var(--color-primary); }
|
||||
.conv-search-input::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
@@ -443,22 +546,29 @@ onUnmounted(() => {
|
||||
|
||||
.bulk-count { font-size: 0.75rem; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.bulk-link {
|
||||
background: none; border: none; color: var(--color-primary);
|
||||
background: none; border: none; color: var(--color-text-secondary);
|
||||
font-size: 0.75rem; cursor: pointer; padding: 0; text-decoration: underline;
|
||||
}
|
||||
.bulk-link:hover { color: var(--color-text); }
|
||||
|
||||
/* Destructive action — Oxblood per Hybrid rule, paired with Trash2 icon */
|
||||
.bulk-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
padding: 0.2rem 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bulk-delete-btn:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.bulk-delete-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.bulk-delete-btn.confirm { background: var(--color-danger, #e74c3c); color: #fff; }
|
||||
.bulk-delete-btn.confirm { background: var(--color-action-destructive-hover); color: #fff; border-color: var(--color-action-destructive-hover); }
|
||||
|
||||
.conv-checkbox {
|
||||
flex-shrink: 0;
|
||||
@@ -478,7 +588,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.conv-group-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -499,7 +609,7 @@ onUnmounted(() => {
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.conv-item:hover { background: rgba(99,102,241,0.05); border-left-color: var(--color-primary); }
|
||||
.conv-item:hover { background: rgba(91, 74, 138,0.05); border-left-color: var(--color-primary); }
|
||||
.conv-item.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
@@ -512,7 +622,7 @@ onUnmounted(() => {
|
||||
background: none; border: none; color: var(--color-text-muted);
|
||||
cursor: pointer; font-size: 1.2rem; padding: 0 0.25rem; line-height: 1;
|
||||
}
|
||||
.btn-delete-conv:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-delete-conv:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
@@ -522,12 +632,12 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ChatPanel fills the remaining space in chat-main */
|
||||
/* ChatPanel fills the remaining space in chat-main. Layout (grid vs
|
||||
* flex) is owned by ChatPanel's own .chat-full root — don't force a
|
||||
* display here or it overrides the grid. */
|
||||
.chat-panel-fill {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
@@ -548,87 +658,138 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-summarize {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-summarize:hover:not(:disabled) {
|
||||
background: var(--color-primary); color: #fff; border-color: var(--color-primary);
|
||||
}
|
||||
.btn-summarize:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
.btn-attach {
|
||||
/* Kebab button + dropdown menu — shared by header and sidebar */
|
||||
.btn-kebab {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
padding: 0.2rem;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-attach:hover { opacity: 1; }
|
||||
.btn-attach:disabled { opacity: 0.25; cursor: default; }
|
||||
.btn-kebab:hover { color: var(--color-text); background: var(--color-bg-secondary); }
|
||||
.btn-kebab.active { color: var(--color-primary); }
|
||||
|
||||
.research-wrapper { position: relative; }
|
||||
.research-modal {
|
||||
.header-kebab-wrapper,
|
||||
.sidebar-kebab-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* RAG scope chip (header) */
|
||||
.scope-chip-wrapper { position: relative; flex-shrink: 0; }
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
|
||||
@keyframes pulse-chip {
|
||||
0%, 100% { border-color: var(--color-border); }
|
||||
50% {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 0 6px rgba(91, 74, 138, 0.4);
|
||||
}
|
||||
}
|
||||
.scope-dot { font-size: 0.6rem; }
|
||||
.scope-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scope-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.75rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-option:hover { background: var(--color-bg-secondary); }
|
||||
.scope-option.active { color: var(--color-primary); font-weight: 500; }
|
||||
|
||||
/* Context toggle button (header) */
|
||||
.btn-context-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-context-toggle:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-context-toggle.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.context-icon { font-size: 0.85rem; line-height: 1; }
|
||||
.context-count-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.kebab-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 20px var(--color-shadow);
|
||||
padding: 0.75rem;
|
||||
min-width: 280px;
|
||||
padding: 0.25rem;
|
||||
z-index: 20;
|
||||
}
|
||||
.research-modal-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.research-topic-input {
|
||||
.kebab-menu--right { left: auto; right: 0; }
|
||||
.kebab-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.research-topic-input:focus { border-color: var(--color-primary); }
|
||||
.research-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn-research-cancel {
|
||||
background: none; border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm); padding: 0.3rem 0.65rem;
|
||||
font-size: 0.85rem; cursor: pointer; color: var(--color-text-muted);
|
||||
}
|
||||
.btn-research-cancel:hover { border-color: var(--color-text-muted); }
|
||||
.btn-research-go {
|
||||
background: var(--color-primary); color: #fff; border: none;
|
||||
border-radius: var(--radius-sm); padding: 0.3rem 0.75rem;
|
||||
font-size: 0.85rem; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.btn-research-go:disabled { opacity: 0.4; cursor: default; }
|
||||
.btn-research-go:not(:disabled):hover { opacity: 0.9; }
|
||||
.kebab-item:hover:not(:disabled) { background: var(--color-bg-secondary); color: var(--color-primary); }
|
||||
.kebab-item:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.no-conversation {
|
||||
flex: 1;
|
||||
@@ -639,6 +800,9 @@ onUnmounted(() => {
|
||||
gap: 1rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.no-conversation .btn-new-conv {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { forceSimulation, forceLink, forceManyBody, forceX, forceY, forceCollide, SimulationNodeDatum, SimulationLinkDatum, Simulation } from "d3-force";
|
||||
@@ -543,7 +544,7 @@ onUnmounted(() => {
|
||||
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}/edit` : `/notes/${peekNode.id}/edit`"
|
||||
class="peek-link"
|
||||
>Edit</router-link>
|
||||
<button class="peek-close" @click="closePeek" title="Close (Esc)">✕</button>
|
||||
<button class="peek-close" @click="closePeek" title="Close (Esc)"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -758,7 +759,7 @@ onUnmounted(() => {
|
||||
|
||||
.tooltip-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@@ -820,7 +821,7 @@ onUnmounted(() => {
|
||||
|
||||
.peek-type-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
@@ -859,7 +860,7 @@ onUnmounted(() => {
|
||||
margin: 0;
|
||||
padding: 0.75rem 0.75rem 0.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -897,7 +898,7 @@ onUnmounted(() => {
|
||||
|
||||
.peek-linked-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -932,7 +933,7 @@ onUnmounted(() => {
|
||||
|
||||
.peek-linked-type {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -569,7 +569,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1.75rem;
|
||||
box-shadow: 0 2px 16px rgba(99, 102, 241, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 2px 16px rgba(91, 74, 138, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
@@ -585,14 +585,14 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
}
|
||||
.hero-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -605,27 +605,27 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1.1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.3);
|
||||
box-shadow: 0 1px 6px rgba(91, 74, 138, 0.3);
|
||||
transition: opacity 0.15s, box-shadow 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-workspace-hero:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 3px 14px rgba(99, 102, 241, 0.45);
|
||||
box-shadow: 0 3px 14px rgba(91, 74, 138, 0.45);
|
||||
}
|
||||
|
||||
.hero-milestones { margin-bottom: 0.75rem; }
|
||||
|
||||
.hero-section-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -688,7 +688,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.section-header h2 { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||
.section-header h2 { margin: 0; font-size: 1rem; font-weight: 500; }
|
||||
.see-all { font-size: 0.85rem; color: var(--color-primary); text-decoration: none; }
|
||||
.see-all:hover { text-decoration: underline; }
|
||||
|
||||
@@ -718,7 +718,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
}
|
||||
.project-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
@@ -760,8 +760,8 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
font-weight: 500;
|
||||
}
|
||||
.urgency-in-progress {
|
||||
background: color-mix(in srgb, #6366f1 15%, transparent);
|
||||
color: #6366f1;
|
||||
background: color-mix(in srgb, #5B4A8A 15%, transparent);
|
||||
color: #5B4A8A;
|
||||
}
|
||||
.urgency-todo {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
@@ -771,7 +771,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
background: color-mix(in srgb, #22c55e 12%, transparent);
|
||||
color: #16a34a;
|
||||
}
|
||||
.urgency-loading { color: var(--color-text-muted); font-style: italic; }
|
||||
.urgency-loading { color: var(--color-text-muted); }
|
||||
|
||||
/* ─── Inbox ──────────────────────────────────────────────────── */
|
||||
.inbox-section {
|
||||
@@ -799,7 +799,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
.inbox-header h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -892,14 +892,14 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
width: 100%;
|
||||
}
|
||||
.upcoming-event-card:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card));
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card));
|
||||
}
|
||||
.upcoming-event-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -911,7 +911,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
}
|
||||
.upcoming-event-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import ChatPanel from '@/components/ChatPanel.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import { RotateCcw } from 'lucide-vue-next'
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
getJournalToday,
|
||||
getJournalDay,
|
||||
getJournalDays,
|
||||
triggerJournalPrep,
|
||||
listEvents,
|
||||
type EventEntry,
|
||||
} from '@/api/client'
|
||||
|
||||
interface WeatherDay {
|
||||
day: string
|
||||
condition: string
|
||||
high: number
|
||||
low: number
|
||||
precip_probability: number | null
|
||||
precip_mm: number | null
|
||||
windspeed_max: number
|
||||
}
|
||||
interface WeatherData {
|
||||
location: string
|
||||
fetched_at: string
|
||||
current_temp: number
|
||||
condition: string
|
||||
today_high: number | null
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
forecast: WeatherDay[]
|
||||
}
|
||||
interface CurrentConditions {
|
||||
temperature: number | null
|
||||
windspeed: number | null
|
||||
description: string
|
||||
precip_next_3h: number[]
|
||||
temp_unit: string
|
||||
location: string
|
||||
}
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// ── Day picker + conversation state ──────────────────────────────────────────
|
||||
const days = ref<string[]>([])
|
||||
const todayDate = ref<string | null>(null)
|
||||
const selectedDay = ref<string | null>(null)
|
||||
const dayConvId = ref<number | null>(null)
|
||||
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
|
||||
|
||||
// ── Weather panel ────────────────────────────────────────────────────────────
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
const selectedWeatherIdx = ref(0)
|
||||
const tempUnit = ref<string>('C')
|
||||
const currentConditions = ref<CurrentConditions | null>(null)
|
||||
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadCurrentConditions() {
|
||||
try {
|
||||
currentConditions.value = await apiGet<CurrentConditions>('/api/journal/weather/current')
|
||||
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
|
||||
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadWeather() {
|
||||
try {
|
||||
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/journal/weather')
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const refreshingWeather = ref(false)
|
||||
async function refreshWeather() {
|
||||
refreshingWeather.value = true
|
||||
try {
|
||||
const data = await apiPost<{ locations: WeatherData[]; temp_unit: string }>('/api/journal/weather/refresh', {})
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
finally { refreshingWeather.value = false }
|
||||
}
|
||||
|
||||
// ── Upcoming events ──────────────────────────────────────────────────────────
|
||||
const upcomingEvents = ref<EventEntry[]>([])
|
||||
|
||||
interface GroupedDay {
|
||||
label: string
|
||||
dateKey: string
|
||||
events: EventEntry[]
|
||||
}
|
||||
|
||||
const groupedEvents = computed<GroupedDay[]>(() => {
|
||||
const groups = new Map<string, EventEntry[]>()
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
for (const ev of upcomingEvents.value) {
|
||||
const d = new Date(ev.start_dt)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(ev)
|
||||
}
|
||||
const result: GroupedDay[] = []
|
||||
for (const [key, events] of groups) {
|
||||
const d = new Date(key + 'T00:00:00')
|
||||
const diff = Math.round((d.getTime() - today.getTime()) / 86_400_000)
|
||||
let label: string
|
||||
if (diff === 0) label = 'Today'
|
||||
else if (diff === 1) label = 'Tomorrow'
|
||||
else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
result.push({ label, dateKey: key, events })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function formatEventTime(ev: EventEntry): string {
|
||||
if (ev.all_day) return 'All day'
|
||||
const d = new Date(ev.start_dt)
|
||||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
// Window: today 00:00 → 14 days out. Matches the prep's framing — events
|
||||
// earlier today (already past) still surface here, grouped under "Today",
|
||||
// so the right rail aligns with what the prep references.
|
||||
const start = new Date()
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 14)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
upcomingEvents.value = await listEvents(start.toISOString(), end.toISOString())
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Day load + switch ────────────────────────────────────────────────────────
|
||||
async function loadDay(iso: string) {
|
||||
const payload = iso === todayDate.value ? await getJournalToday() : await getJournalDay(iso)
|
||||
if (payload.conversation) {
|
||||
dayConvId.value = payload.conversation.id
|
||||
await chatStore.fetchConversation(payload.conversation.id)
|
||||
} else {
|
||||
dayConvId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const today = await getJournalToday()
|
||||
todayDate.value = today.day_date
|
||||
selectedDay.value = today.day_date
|
||||
if (today.conversation) {
|
||||
dayConvId.value = today.conversation.id
|
||||
await chatStore.fetchConversation(today.conversation.id)
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
|
||||
try {
|
||||
days.value = await getJournalDays()
|
||||
if (todayDate.value && !days.value.includes(todayDate.value)) {
|
||||
days.value = [todayDate.value, ...days.value]
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
|
||||
await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents()])
|
||||
}
|
||||
|
||||
watch(selectedDay, async (iso) => {
|
||||
if (!iso || iso === todayDate.value) return
|
||||
try { await loadDay(iso) } catch { /* silent */ }
|
||||
})
|
||||
|
||||
// ── Manual prep regeneration ─────────────────────────────────────────────────
|
||||
const triggering = ref(false)
|
||||
async function triggerPrep() {
|
||||
if (triggering.value) return
|
||||
triggering.value = true
|
||||
try {
|
||||
await triggerJournalPrep()
|
||||
if (_mounted) await loadAll()
|
||||
} finally {
|
||||
if (_mounted) triggering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function dayLabel(iso: string): string {
|
||||
if (iso === todayDate.value) return 'Today'
|
||||
const d = new Date(iso + 'T00:00:00')
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const todayBadge = computed(() => {
|
||||
return new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
})
|
||||
|
||||
// ── Background refresh ───────────────────────────────────────────────────────
|
||||
async function _backgroundRefreshMessages() {
|
||||
try {
|
||||
if (!_mounted || !isToday.value || !dayConvId.value) return
|
||||
await chatStore.fetchConversation(dayConvId.value)
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
useBackgroundRefresh(
|
||||
_backgroundRefreshMessages,
|
||||
60_000,
|
||||
() => !chatStore.streaming && isToday.value && !!dayConvId.value,
|
||||
)
|
||||
|
||||
let _mounted = true
|
||||
onUnmounted(() => {
|
||||
_mounted = false
|
||||
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="journal-root">
|
||||
<div class="journal-shell">
|
||||
<header class="journal-header">
|
||||
<div class="journal-header-left">
|
||||
<h1 class="journal-title">Journal</h1>
|
||||
<span class="journal-today-badge">{{ todayBadge }}</span>
|
||||
</div>
|
||||
<div class="journal-header-right">
|
||||
<select v-if="days.length" v-model="selectedDay" class="journal-day-select">
|
||||
<option v-for="d in days" :key="d" :value="d">{{ dayLabel(d) }}</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn-trigger"
|
||||
@click="triggerPrep"
|
||||
:disabled="triggering || !isToday"
|
||||
title="Regenerate today's daily prep"
|
||||
>{{ triggering ? '…' : 'Refresh prep' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Center: chat (prep is the first assistant message inside) -->
|
||||
<div class="journal-center">
|
||||
<ChatPanel
|
||||
variant="full"
|
||||
briefingMode
|
||||
:readOnly="!isToday"
|
||||
placeholder="Tell your journal…"
|
||||
class="journal-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: Weather + Events + News -->
|
||||
<div class="journal-right">
|
||||
<div class="weather-section" v-if="weatherData.length">
|
||||
<div class="weather-section-header">
|
||||
<div class="weather-tabs" v-if="weatherData.length > 1">
|
||||
<button
|
||||
v-for="(loc, i) in weatherData"
|
||||
:key="(loc as WeatherData).location"
|
||||
class="weather-tab"
|
||||
:class="{ active: selectedWeatherIdx === i }"
|
||||
@click="selectedWeatherIdx = i"
|
||||
>{{ (loc as WeatherData).location }}</button>
|
||||
</div>
|
||||
<button
|
||||
class="weather-refresh-btn"
|
||||
:class="{ spinning: refreshingWeather }"
|
||||
:disabled="refreshingWeather"
|
||||
@click="refreshWeather"
|
||||
title="Refresh weather"
|
||||
>
|
||||
<RotateCcw :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<WeatherCard
|
||||
:weather="weatherData[selectedWeatherIdx]"
|
||||
:temp-unit="tempUnit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="events-section" v-if="groupedEvents.length">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Upcoming</div>
|
||||
<router-link to="/calendar" class="events-cal-link">Calendar →</router-link>
|
||||
</div>
|
||||
<div class="events-list">
|
||||
<div v-for="group in groupedEvents" :key="group.dateKey" class="events-day-group">
|
||||
<div class="events-day-label">{{ group.label }}</div>
|
||||
<div v-for="ev in group.events" :key="ev.id" class="event-row">
|
||||
<span class="event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
||||
<span class="event-body">
|
||||
<span class="event-title">{{ ev.title }}</span>
|
||||
<span class="event-time">{{ formatEventTime(ev) }}</span>
|
||||
<span v-if="ev.location" class="event-loc">{{ ev.location }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.journal-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.journal-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(320px, 35%);
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.journal-header {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.journal-header-left { display: flex; align-items: baseline; gap: 0.75rem; }
|
||||
.journal-title {
|
||||
/* h1 inherits Fraunces from theme.css; weight 500 follows the doc's "two weights only" rule */
|
||||
font-size: 1.3rem;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.journal-today-badge { font-size: 0.82rem; color: var(--color-text-muted); }
|
||||
.journal-header-right { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.journal-day-select {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-trigger {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-trigger:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Center column: prep card + chat ──────────────────────────────────────── */
|
||||
.journal-center {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.journal-chat-panel { flex: 1; min-height: 0; }
|
||||
|
||||
/* ── Right column ─────────────────────────────────────────────────────────── */
|
||||
.journal-right {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.weather-section { flex-shrink: 0; padding: 1rem 1rem 0.5rem; }
|
||||
.weather-section :deep(.weather-card) { margin-bottom: 0; }
|
||||
.weather-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.45rem;
|
||||
line-height: 1;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.weather-refresh-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.weather-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.weather-refresh-btn.spinning { animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.weather-tabs { display: flex; gap: 0.25rem; }
|
||||
.weather-tab {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.weather-tab:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.weather-tab.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.events-section { padding: 0.75rem 1rem; border-top: 1px solid var(--color-border); }
|
||||
.events-cal-link { font-size: 0.75rem; color: var(--color-text-muted); text-decoration: none; }
|
||||
.events-cal-link:hover { color: var(--color-primary); }
|
||||
.events-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.events-day-group { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.events-day-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.event-row { display: flex; align-items: flex-start; gap: 0.4rem; padding: 0.25rem 0.4rem; border-radius: 6px; }
|
||||
.event-row:hover { background: color-mix(in srgb, var(--color-primary) 6%, transparent); }
|
||||
.event-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0; margin-top: 5px;
|
||||
}
|
||||
.event-body { display: flex; flex-direction: column; gap: 0.05rem; min-width: 0; }
|
||||
.event-title { font-size: 0.82rem; font-weight: 500; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.event-time { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.event-loc { font-size: 0.7rem; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel-label-row { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.journal-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
|
||||
.journal-center { grid-column: 1; grid-row: 2; }
|
||||
.journal-right {
|
||||
grid-column: 1; grid-row: 3;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,20 @@ import { useChatStore } from "@/stores/chat";
|
||||
import GraphView from "@/views/GraphView.vue";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import ChatInputBar from "@/components/ChatInputBar.vue";
|
||||
import {
|
||||
FileText,
|
||||
CheckSquare,
|
||||
User,
|
||||
MapPin,
|
||||
List,
|
||||
Search,
|
||||
Share2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const router = useRouter();
|
||||
const chatStore = useChatStore();
|
||||
@@ -15,7 +29,7 @@ const chatStore = useChatStore();
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "person" | "place" | "list";
|
||||
note_type: "note" | "person" | "place" | "list" | "task";
|
||||
title: string;
|
||||
snippet: string;
|
||||
tags: string[];
|
||||
@@ -27,12 +41,20 @@ interface KnowledgeItem {
|
||||
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
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
}
|
||||
|
||||
interface UpcomingEvent {
|
||||
@@ -44,7 +66,7 @@ interface UpcomingEvent {
|
||||
|
||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
|
||||
const activeTag = ref("");
|
||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||
const searchQuery = ref("");
|
||||
@@ -69,7 +91,18 @@ const newNoteMenuOpen = ref(false);
|
||||
|
||||
function createNew(type: string) {
|
||||
newNoteMenuOpen.value = false;
|
||||
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
|
||||
if (type === "task") {
|
||||
router.push("/tasks/new");
|
||||
} else {
|
||||
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutsideNewNote(e: MouseEvent) {
|
||||
const wrap = document.querySelector('.new-note-wrap');
|
||||
if (wrap && !wrap.contains(e.target as Node)) {
|
||||
newNoteMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Two-tier pagination ──────────────────────────────────────────────────────
|
||||
@@ -228,7 +261,7 @@ const chatCollapsed = ref(false);
|
||||
const chatConvId = ref<number | null>(null);
|
||||
async function onMinichatSubmit(payload: { content: string; contextNoteId?: number }) {
|
||||
if (!chatConvId.value) {
|
||||
const conv = await chatStore.createConversation("Knowledge chat");
|
||||
const conv = await chatStore.createConversation();
|
||||
chatConvId.value = conv.id;
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
} else if (chatStore.currentConversation?.id !== chatConvId.value) {
|
||||
@@ -236,7 +269,7 @@ async function onMinichatSubmit(payload: { content: string; contextNoteId?: numb
|
||||
}
|
||||
chatOpen.value = true;
|
||||
chatCollapsed.value = false;
|
||||
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, true);
|
||||
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, false);
|
||||
}
|
||||
|
||||
// ─── Auto-refresh cards when chat creates/edits notes or tasks ───────────────
|
||||
@@ -307,8 +340,17 @@ async function toggleListItem(item: KnowledgeItem, index: number) {
|
||||
|
||||
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
function openItem(item: KnowledgeItem) {
|
||||
router.push(`/notes/${item.id}`);
|
||||
if (item.note_type === 'task') {
|
||||
router.push(`/tasks/${item.id}`);
|
||||
} else {
|
||||
router.push(`/notes/${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -344,6 +386,7 @@ function setupObserver() {
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', onClickOutsideNewNote);
|
||||
await reset();
|
||||
fetchTags();
|
||||
fetchCounts();
|
||||
@@ -353,6 +396,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutsideNewNote);
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
observer?.disconnect();
|
||||
});
|
||||
@@ -380,7 +424,6 @@ onUnmounted(() => {
|
||||
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
|
||||
{{ overdueCount }} overdue
|
||||
</router-link>
|
||||
<router-link to="/briefing" class="today-link">Briefing</router-link>
|
||||
<router-link to="/chat" class="today-link">Chat</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,13 +435,30 @@ onUnmounted(() => {
|
||||
<aside class="filter-panel">
|
||||
<!-- New note button -->
|
||||
<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>
|
||||
<button class="btn-new-note" @click="newNoteMenuOpen = !newNoteMenuOpen">
|
||||
<span class="btn-new-icon">+</span> New
|
||||
</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>
|
||||
<button @click="createNew('note')">
|
||||
<FileText :size="16" />
|
||||
Note
|
||||
</button>
|
||||
<button @click="createNew('task')">
|
||||
<CheckSquare :size="16" />
|
||||
Task
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -413,11 +473,11 @@ onUnmounted(() => {
|
||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
||||
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][])"
|
||||
:key="val"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeType === val }"
|
||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
|
||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
|
||||
>
|
||||
<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>
|
||||
@@ -447,7 +507,7 @@ onUnmounted(() => {
|
||||
<!-- Toolbar -->
|
||||
<div class="knowledge-toolbar">
|
||||
<div class="search-wrap">
|
||||
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<Search class="search-icon" :size="16" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="onSearchInput"
|
||||
@@ -463,10 +523,7 @@ onUnmounted(() => {
|
||||
<option value="type">By type</option>
|
||||
</select>
|
||||
<button class="btn-graph" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<path d="m8.59 13.51 6.83 3.98M15.41 6.51l-6.82 3.98"/>
|
||||
</svg>
|
||||
<Share2 :size="16" />
|
||||
Graph
|
||||
</button>
|
||||
</div>
|
||||
@@ -474,9 +531,8 @@ onUnmounted(() => {
|
||||
<!-- Loading / empty -->
|
||||
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Card grid -->
|
||||
@@ -493,6 +549,7 @@ onUnmounted(() => {
|
||||
<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'">Task</span>
|
||||
<span v-else>List</span>
|
||||
</span>
|
||||
|
||||
@@ -502,11 +559,13 @@ onUnmounted(() => {
|
||||
<!-- 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>
|
||||
@@ -533,6 +592,26 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
@@ -562,12 +641,12 @@ onUnmounted(() => {
|
||||
@click="toggleGraphExpand"
|
||||
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline v-if="graphExpanded" points="15 18 9 12 15 6"/>
|
||||
<polyline v-else points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
<ChevronLeft v-if="graphExpanded" :size="16" />
|
||||
<ChevronRight v-else :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-embed">
|
||||
@@ -588,12 +667,12 @@ onUnmounted(() => {
|
||||
@click="chatCollapsed = !chatCollapsed"
|
||||
:title="chatCollapsed ? 'Expand' : 'Collapse'"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline v-if="chatCollapsed" points="18 15 12 9 6 15"/>
|
||||
<polyline v-else points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<ChevronUp v-if="chatCollapsed" :size="16" />
|
||||
<ChevronDown v-else :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="closeChat" title="Close chat">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="closeChat" title="Close chat">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -652,17 +731,17 @@ onUnmounted(() => {
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
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(99, 102, 241, 0.18); }
|
||||
.today-event-chip:hover { background: rgba(91, 74, 138, 0.18); }
|
||||
.chip-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
background: #5B4A8A;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
|
||||
@@ -677,7 +756,7 @@ onUnmounted(() => {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.today-link {
|
||||
color: var(--color-primary, #6366f1);
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
@@ -703,73 +782,87 @@ onUnmounted(() => {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.filter-section { margin-bottom: 20px; }
|
||||
.filter-section + .filter-section::before {
|
||||
content: '· · ·';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: rgba(91, 74, 138, 0.3);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.4em;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 6px;
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
/* ── New note button ─────────────────────────────────────── */
|
||||
/* ── New item button ─────────────────────────────────────── */
|
||||
.new-note-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.btn-new-note {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px 0 0 8px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||
border-right: none;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--color-primary, #818cf8);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
|
||||
.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;
|
||||
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
|
||||
.btn-new-icon {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
font-weight: 300;
|
||||
}
|
||||
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
|
||||
.btn-new-chevron.open { transform: scaleY(-1); }
|
||||
.new-note-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-tertiary, #1a1b1e);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
z-index: 50;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.new-note-menu button {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
padding: 9px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
text-align: left;
|
||||
transition: background 0.12s;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.new-note-menu button:hover {
|
||||
background: var(--color-primary-tint);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.new-note-menu button svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.new-note-menu button:hover svg {
|
||||
opacity: 1;
|
||||
stroke: var(--color-primary);
|
||||
}
|
||||
.new-note-menu button:hover { background: rgba(99, 102, 241, 0.12); }
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
@@ -790,8 +883,8 @@ onUnmounted(() => {
|
||||
}
|
||||
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
|
||||
.filter-btn.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: var(--color-primary, #818cf8);
|
||||
background: var(--color-primary-wash);
|
||||
color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
.filter-btn-label { flex: 1; }
|
||||
@@ -807,8 +900,8 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-btn.active .filter-count {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: var(--color-primary, #818cf8);
|
||||
background: rgba(91, 74, 138, 0.2);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.filter-tag { font-size: 0.78rem; }
|
||||
|
||||
@@ -853,7 +946,7 @@ onUnmounted(() => {
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--color-primary, #6366f1); }
|
||||
.search-input:focus { border-color: var(--color-primary); }
|
||||
.sort-select {
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
@@ -880,9 +973,9 @@ onUnmounted(() => {
|
||||
}
|
||||
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
|
||||
.btn-graph.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
color: var(--color-primary, #818cf8);
|
||||
background: var(--color-primary-wash);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Card grid ───────────────────────────────────────────── */
|
||||
@@ -890,6 +983,8 @@ onUnmounted(() => {
|
||||
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);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
@@ -911,16 +1006,56 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
.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);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
/* Type-specific card DNA */
|
||||
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
||||
.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 */
|
||||
.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, #5B4A8A, #7A6DA8);
|
||||
}
|
||||
.k-card--task::before {
|
||||
right: 0;
|
||||
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 {
|
||||
@@ -930,18 +1065,19 @@ onUnmounted(() => {
|
||||
font-size: 0.68rem;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.badge--note { background: rgba(99,102,241,0.15); color: #818cf8; }
|
||||
.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; }
|
||||
|
||||
.k-card-body { flex: 1; padding-right: 40px; }
|
||||
.k-card-title {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.3;
|
||||
@@ -1038,7 +1174,48 @@ onUnmounted(() => {
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
|
||||
|
||||
/* ── Task card ──────────────────────────────────────────── */
|
||||
.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: 500;
|
||||
}
|
||||
.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: 500;
|
||||
}
|
||||
.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-secondary);
|
||||
}
|
||||
.task-overdue {
|
||||
color: var(--color-overdue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Empty / loading ─────────────────────────────────────── */
|
||||
.knowledge-empty {
|
||||
@@ -1053,6 +1230,12 @@ onUnmounted(() => {
|
||||
gap: 6px;
|
||||
}
|
||||
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
|
||||
.empty-narrator {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── Sentinel ────────────────────────────────────────────── */
|
||||
.scroll-sentinel {
|
||||
@@ -1086,7 +1269,7 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1149,7 +1332,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.minichat-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getBriefingFeeds,
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
openArticleInChat,
|
||||
type BriefingFeed,
|
||||
} from '@/api/client'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const LIMIT = 40
|
||||
|
||||
const items = ref<NewsItem[]>([])
|
||||
const offset = ref(0)
|
||||
const hasMore = ref(true)
|
||||
const loading = ref(false)
|
||||
const feeds = ref<BriefingFeed[]>([])
|
||||
const selectedFeedId = ref<number | null>(null)
|
||||
|
||||
// Reactions map: item id → current reaction
|
||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
||||
// Track which items are currently being opened in chat
|
||||
const openingChat = ref<Set<number>>(new Set())
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || !hasMore.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getNewsItems({
|
||||
days: 90,
|
||||
limit: LIMIT,
|
||||
offset: offset.value,
|
||||
feed_id: selectedFeedId.value,
|
||||
})
|
||||
for (const item of data.items) {
|
||||
if (reactions.value[item.id] === undefined) {
|
||||
reactions.value[item.id] = item.reaction
|
||||
}
|
||||
}
|
||||
items.value = [...items.value, ...data.items]
|
||||
offset.value += data.items.length
|
||||
hasMore.value = data.items.length === LIMIT
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFeedChange() {
|
||||
items.value = []
|
||||
offset.value = 0
|
||||
hasMore.value = true
|
||||
reactions.value = {}
|
||||
loadMore()
|
||||
}
|
||||
|
||||
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
|
||||
const current = reactions.value[itemId]
|
||||
reactions.value[itemId] = current === reaction ? null : reaction
|
||||
try {
|
||||
if (current === reaction) {
|
||||
await deleteRssReaction(itemId)
|
||||
} else {
|
||||
await postRssReaction(itemId, reaction)
|
||||
}
|
||||
} catch {
|
||||
reactions.value[itemId] = current ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
||||
if (diffH < 1) return 'Just now'
|
||||
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
||||
if (diffH < 48) return 'Yesterday'
|
||||
const days = Math.floor(diffH / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
async function openInChat(itemId: number) {
|
||||
if (openingChat.value.has(itemId)) return
|
||||
openingChat.value.add(itemId)
|
||||
try {
|
||||
const result = await openArticleInChat(itemId)
|
||||
router.push(`/chat/${result.conversation_id}`)
|
||||
} catch {
|
||||
// silently fail — button returns to enabled state
|
||||
} finally {
|
||||
openingChat.value.delete(itemId)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
feeds.value = await getBriefingFeeds().catch(() => [])
|
||||
await loadMore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="news-root">
|
||||
<div class="news-header">
|
||||
<div class="news-header-left">
|
||||
<h1 class="news-title">News</h1>
|
||||
<span class="news-subtitle">Last 90 days</span>
|
||||
</div>
|
||||
<div class="news-header-right">
|
||||
<select
|
||||
v-model="selectedFeedId"
|
||||
class="feed-select"
|
||||
@change="onFeedChange"
|
||||
>
|
||||
<option :value="null">All feeds</option>
|
||||
<option v-for="feed in feeds" :key="feed.id" :value="feed.id">
|
||||
{{ feed.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="news-list">
|
||||
<div v-if="!items.length && !loading" class="news-empty">
|
||||
No articles found for the selected feed.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="news-card"
|
||||
>
|
||||
<div class="news-card-meta">
|
||||
<span class="news-source">{{ item.source }}</span>
|
||||
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="item.url"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="news-card-title"
|
||||
>{{ item.title }}</a>
|
||||
<p v-else class="news-card-title news-card-title--plain">{{ item.title }}</p>
|
||||
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
||||
<div v-if="item.topics?.length" class="news-topics">
|
||||
<span v-for="topic in item.topics" :key="topic" class="news-topic">{{ topic }}</span>
|
||||
</div>
|
||||
<div class="news-reactions">
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'up' }"
|
||||
@click="handleReaction(item.id, 'up')"
|
||||
title="Interested"
|
||||
>👍</button>
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'down' }"
|
||||
@click="handleReaction(item.id, 'down')"
|
||||
title="Not interested"
|
||||
>👎</button>
|
||||
<button
|
||||
class="reaction-btn open-chat-btn"
|
||||
:class="{ busy: openingChat.has(item.id) }"
|
||||
:disabled="openingChat.has(item.id)"
|
||||
@click="openInChat(item.id)"
|
||||
title="Discuss in chat"
|
||||
>{{ openingChat.has(item.id) ? '…' : '💬' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="news-footer">
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="btn-load-more"
|
||||
@click="loadMore"
|
||||
:disabled="loading"
|
||||
>{{ loading ? 'Loading…' : 'Load more' }}</button>
|
||||
<div v-if="loading && !items.length" class="news-loading">Loading…</div>
|
||||
<p v-if="!hasMore && items.length" class="news-end">All articles loaded</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.news-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.news-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.news-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.news-subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.feed-select {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.news-list {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 860px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.news-empty,
|
||||
.news-loading {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.news-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.news-source {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-card-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
text-decoration: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a.news-card-title:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-snippet {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.news-topic {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
color: var(--color-primary);
|
||||
border-radius: 99px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.news-reactions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.reaction-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.reaction-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.open-chat-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.open-chat-btn.busy {
|
||||
opacity: 0.4;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.news-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.btn-load-more {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-load-more:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-load-more:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.news-end {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -21,6 +21,7 @@ import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { Trash2 } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -38,6 +39,81 @@ const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
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 titleRef = ref<HTMLInputElement | null>(null);
|
||||
const tiptapEditor = computed<Editor | null>(() => {
|
||||
@@ -49,6 +125,15 @@ const noteId = computed(() =>
|
||||
);
|
||||
const isEditing = computed(() => noteId.value !== null);
|
||||
|
||||
const titlePlaceholder = computed(() => {
|
||||
switch (noteType.value) {
|
||||
case 'person': return 'Name';
|
||||
case 'place': return 'Place name';
|
||||
case 'list': return 'List title';
|
||||
default: return 'Title';
|
||||
}
|
||||
});
|
||||
|
||||
const renderedPreview = computed(() => renderMarkdown(body.value));
|
||||
|
||||
// AI Assist — pass noteId for draft persistence
|
||||
@@ -219,6 +304,13 @@ onMounted(async () => {
|
||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||
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;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
@@ -233,6 +325,9 @@ onMounted(async () => {
|
||||
if (qt && ["note", "person", "place", "list"].includes(qt)) {
|
||||
noteType.value = qt as NoteType;
|
||||
}
|
||||
if (noteType.value === 'list') {
|
||||
listItems.value = [{ text: '', checked: false }];
|
||||
}
|
||||
}
|
||||
|
||||
// Restore pending draft if any
|
||||
@@ -243,6 +338,9 @@ onMounted(async () => {
|
||||
// No draft — normal
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
titleRef.value?.focus();
|
||||
|
||||
// Initial link suggestions
|
||||
fetchLinkSuggestions();
|
||||
});
|
||||
@@ -250,11 +348,12 @@ onMounted(async () => {
|
||||
async function save() {
|
||||
if (saving.value) return;
|
||||
saving.value = true;
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await store.updateNote(noteId.value!, {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
body: finalBody,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
@@ -273,7 +372,7 @@ async function save() {
|
||||
} else {
|
||||
const note = await store.createNote({
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
body: finalBody,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
@@ -304,7 +403,7 @@ async function confirmDelete() {
|
||||
await store.deleteNote(noteId.value);
|
||||
dirty.value = false;
|
||||
toast.show("Note deleted");
|
||||
router.push("/notes");
|
||||
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
|
||||
} catch {
|
||||
toast.show("Failed to delete note", "error");
|
||||
}
|
||||
@@ -314,9 +413,10 @@ async function confirmDelete() {
|
||||
async function doAutoSave() {
|
||||
if (!isEditing.value || saving.value) return;
|
||||
saving.value = true;
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
try {
|
||||
await store.updateNote(noteId.value!, {
|
||||
title: title.value, body: body.value, tags: tags.value,
|
||||
title: title.value, body: finalBody, tags: tags.value,
|
||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
||||
note_type: noteType.value, metadata: { ...entityMeta },
|
||||
});
|
||||
@@ -346,18 +446,20 @@ onUnmounted(() => assist.clearSelection());
|
||||
<main class="editor-page note-editor-page">
|
||||
<div class="editor-header">
|
||||
<div class="toolbar">
|
||||
<router-link to="/notes" class="btn-back">← Notes</router-link>
|
||||
<router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back">← {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
|
||||
<button class="btn-save" @click="save" :disabled="saving">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||||
<Trash2 :size="16" /> Delete
|
||||
</button>
|
||||
<WordCount :body="body" />
|
||||
</div>
|
||||
<input
|
||||
ref="titleRef"
|
||||
v-model="title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
:placeholder="titlePlaceholder"
|
||||
class="title-input"
|
||||
@input="markDirty"
|
||||
@keydown.ctrl.s.prevent="save"
|
||||
@@ -370,48 +472,181 @@ onUnmounted(() => assist.clearSelection());
|
||||
|
||||
<!-- ── Main column ────────────────────────────────────────── -->
|
||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||||
<div class="body-tabs-row">
|
||||
<div class="editor-tabs">
|
||||
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
|
||||
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
|
||||
<!-- ── 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>
|
||||
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
|
||||
</div>
|
||||
|
||||
<!-- Streaming preview -->
|
||||
<template v-if="assist.state.value === 'streaming'">
|
||||
<div class="stream-label">Generating...</div>
|
||||
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
|
||||
</template>
|
||||
|
||||
<!-- Review: full-document diff -->
|
||||
<template v-else-if="assist.state.value === 'review'">
|
||||
<DiffView :diff="assist.diff.value" class="main-diff" />
|
||||
</template>
|
||||
|
||||
<!-- Normal editor -->
|
||||
<template v-else>
|
||||
<Transition name="tab-fade" mode="out-in">
|
||||
<div v-if="!showPreview" class="body-editor-wrap">
|
||||
<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="Write your note in Markdown..."
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error from assist -->
|
||||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||||
<!-- ── 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>
|
||||
|
||||
<!-- ── Generic note editor ──────────────────────────────── -->
|
||||
<template v-else>
|
||||
<div class="body-tabs-row">
|
||||
<div class="editor-tabs">
|
||||
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
|
||||
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
|
||||
</div>
|
||||
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
|
||||
</div>
|
||||
|
||||
<!-- Streaming preview -->
|
||||
<template v-if="assist.state.value === 'streaming'">
|
||||
<div class="stream-label">Generating...</div>
|
||||
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
|
||||
</template>
|
||||
|
||||
<!-- Review: full-document diff -->
|
||||
<template v-else-if="assist.state.value === 'review'">
|
||||
<DiffView :diff="assist.diff.value" class="main-diff" />
|
||||
</template>
|
||||
|
||||
<!-- Normal editor -->
|
||||
<template v-else>
|
||||
<Transition name="tab-fade" mode="out-in">
|
||||
<div v-if="!showPreview" class="body-editor-wrap">
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Write your note in Markdown..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
/>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- Error from assist -->
|
||||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── Sidebar ──────────────────────────────────────────── -->
|
||||
@@ -476,38 +711,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Link Suggestions -->
|
||||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||||
<div class="sb-label-row">
|
||||
@@ -692,7 +895,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.stream-preview {
|
||||
@@ -731,7 +933,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sb-select:focus, .sb-input:focus {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
@@ -813,12 +1015,142 @@ onUnmounted(() => assist.clearSelection());
|
||||
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── 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-size: 0.92rem;
|
||||
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 */
|
||||
@media (max-width: 720px) {
|
||||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Note } from "@/types/note";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import TableOfContents from "@/components/TableOfContents.vue";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -238,12 +239,12 @@ async function convertToTask() {
|
||||
<h1 class="note-title">{{ store.currentNote.title || "Untitled" }}</h1>
|
||||
<p class="meta">
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
|
||||
<Clock :size="16" />
|
||||
Updated {{ relativeTime(store.currentNote.updated_at) }}
|
||||
</span>
|
||||
<span class="meta-sep" aria-hidden="true">·</span>
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
<Pencil :size="16" />
|
||||
Created {{ relativeTime(store.currentNote.created_at) }}
|
||||
</span>
|
||||
</p>
|
||||
@@ -265,7 +266,7 @@ async function convertToTask() {
|
||||
|
||||
<div v-if="backlinks.length" class="backlinks">
|
||||
<h3 class="backlinks-heading">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
<LinkIcon :size="16" />
|
||||
Backlinks
|
||||
<span class="backlinks-count">{{ backlinks.length }}</span>
|
||||
</h3>
|
||||
@@ -344,41 +345,39 @@ async function convertToTask() {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Edit: Moss action-primary — switching from view to edit is operating
|
||||
the software, not a brand moment. */
|
||||
.btn-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
opacity: 0.95;
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
@@ -386,21 +385,21 @@ async function convertToTask() {
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.note-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
@@ -439,7 +438,7 @@ async function convertToTask() {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -481,7 +480,7 @@ async function convertToTask() {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||
import SearchBar from "@/components/SearchBar.vue";
|
||||
import NoteCard from "@/components/NoteCard.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
|
||||
type ViewMode = "grid" | "list";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useNotesStore();
|
||||
|
||||
const searchBarRef = ref<{ focus: () => void } | null>(null);
|
||||
|
||||
function onFocusSearch() {
|
||||
searchBarRef.value?.focus();
|
||||
}
|
||||
|
||||
const { activeIndex } = useListKeyboardNavigation(
|
||||
computed(() => store.notes),
|
||||
(note) => router.push(`/notes/${note.id}`),
|
||||
);
|
||||
|
||||
const viewMode = ref<ViewMode>(
|
||||
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
|
||||
);
|
||||
|
||||
function setViewMode(mode: ViewMode) {
|
||||
viewMode.value = mode;
|
||||
localStorage.setItem("fabled-notes-view-mode", mode);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const tag = route.query.tag;
|
||||
if (tag) {
|
||||
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
|
||||
store.setTagFilters(tags);
|
||||
} else {
|
||||
store.refresh();
|
||||
}
|
||||
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("shortcut:focus-search", onFocusSearch);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.tag,
|
||||
(tag) => {
|
||||
if (tag) {
|
||||
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
|
||||
store.setTagFilters(tags);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function onSearch(q: string) {
|
||||
store.setSearch(q);
|
||||
}
|
||||
|
||||
function onTagClick(tag: string) {
|
||||
store.addTagFilter(tag);
|
||||
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
|
||||
}
|
||||
|
||||
function onTagDismiss(tag: string) {
|
||||
store.removeTagFilter(tag);
|
||||
const newQuery = { ...route.query };
|
||||
if (store.activeTagFilters.length > 0) {
|
||||
newQuery.tag = store.activeTagFilters;
|
||||
} else {
|
||||
delete newQuery.tag;
|
||||
}
|
||||
router.replace({ query: newQuery });
|
||||
}
|
||||
|
||||
function onSortChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
store.setSort(value, store.sortOrder);
|
||||
}
|
||||
|
||||
function toggleOrder() {
|
||||
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
|
||||
}
|
||||
|
||||
function onOffsetUpdate(offset: number) {
|
||||
store.setOffset(offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="notes-list">
|
||||
<div class="header">
|
||||
<h1>Notes</h1>
|
||||
<router-link to="/notes/new" class="btn-new">+ New Note</router-link>
|
||||
</div>
|
||||
<SearchBar ref="searchBarRef" @search="onSearch" />
|
||||
|
||||
<div class="controls">
|
||||
<div class="sort-controls">
|
||||
<select :value="store.sortField" @change="onSortChange" class="sort-select">
|
||||
<option value="updated_at">Updated</option>
|
||||
<option value="created_at">Created</option>
|
||||
<option value="title">Title</option>
|
||||
</select>
|
||||
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
|
||||
{{ store.sortOrder === "asc" ? "↑" : "↓" }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- View mode toggle -->
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
:class="['toggle-btn', { active: viewMode === 'grid' }]"
|
||||
title="Grid view"
|
||||
@click="setViewMode('grid')"
|
||||
>⊞</button>
|
||||
<button
|
||||
:class="['toggle-btn', { active: viewMode === 'list' }]"
|
||||
title="Compact list"
|
||||
@click="setViewMode('list')"
|
||||
>☰</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.activeTagFilters.length" class="active-filters">
|
||||
<span class="filter-label">Filtering by:</span>
|
||||
<TagPill
|
||||
v-for="tag in store.activeTagFilters"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
dismissible
|
||||
@dismiss="onTagDismiss"
|
||||
/>
|
||||
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="note-list-skeleton">
|
||||
<div v-if="viewMode === 'grid'" class="skeleton-grid">
|
||||
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
|
||||
</div>
|
||||
<div v-else class="skeleton-rows">
|
||||
<div class="skeleton-row" v-for="i in 8" :key="i"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="store.notes.length === 0" class="empty-state">
|
||||
<template v-if="store.searchQuery || store.activeTagFilters.length">
|
||||
<p class="empty-title">No notes match your filters</p>
|
||||
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="empty-title">No notes yet</p>
|
||||
<p class="empty-subtitle">Create your first note to get started.</p>
|
||||
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Grid view -->
|
||||
<div v-else-if="viewMode === 'grid'" class="cards-grid">
|
||||
<div
|
||||
v-for="(note, i) in store.notes"
|
||||
:key="note.id"
|
||||
:class="{ 'kb-active-item': activeIndex === i }"
|
||||
>
|
||||
<NoteCard :note="note" @tag-click="onTagClick" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact list view -->
|
||||
<div v-else class="cards-list">
|
||||
<div
|
||||
v-for="(note, i) in store.notes"
|
||||
:key="note.id"
|
||||
:class="{ 'kb-active-item': activeIndex === i }"
|
||||
>
|
||||
<NoteCard :note="note" compact @tag-click="onTagClick" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:total="store.total"
|
||||
:limit="store.limit"
|
||||
:offset="store.offset"
|
||||
@update:offset="onOffsetUpdate"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notes-list {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.btn-new {
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-new:hover {
|
||||
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sort-select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sort-order {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.toggle-btn {
|
||||
padding: 0.3rem 0.55rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.active-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.clear-filters {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-danger);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.note-list-skeleton {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 140px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
.skeleton-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.skeleton-row {
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Grid layout */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.cards-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Compact list layout */
|
||||
.cards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.empty-subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-cta {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-cta:hover {
|
||||
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.kb-active-item {
|
||||
outline: 2px solid var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
@@ -20,7 +20,7 @@ interface Project {
|
||||
title: string;
|
||||
description: string | null;
|
||||
goal: string | null;
|
||||
status: "active" | "completed" | "archived";
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
auto_summary: string | null;
|
||||
permission?: string;
|
||||
@@ -40,7 +40,7 @@ const toast = useToastStore();
|
||||
const projects = ref<Project[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const activeTab = ref<"all" | "active" | "completed" | "archived">("all");
|
||||
const activeTab = ref<"all" | "active" | "paused" | "completed" | "archived">("all");
|
||||
|
||||
// New project modal
|
||||
const showNewProjectModal = ref(false);
|
||||
@@ -103,6 +103,7 @@ async function createProject() {
|
||||
|
||||
function statusLabel(status: Project["status"]): string {
|
||||
if (status === "active") return "Active";
|
||||
if (status === "paused") return "Paused";
|
||||
if (status === "completed") return "Completed";
|
||||
if (status === "archived") return "Archived";
|
||||
return status;
|
||||
@@ -112,6 +113,14 @@ function truncate(text: string | null, max = 120): string {
|
||||
if (!text) return "";
|
||||
return text.length > max ? text.slice(0, max) + "..." : text;
|
||||
}
|
||||
|
||||
function overallPct(project: Project): { total: number; pct: number } {
|
||||
const counts = project.summary?.task_counts;
|
||||
if (!counts) return { total: 0, pct: 0 };
|
||||
const total = (counts.todo ?? 0) + (counts.in_progress ?? 0) + (counts.done ?? 0);
|
||||
const pct = total > 0 ? Math.round((counts.done ?? 0) / total * 100) : 0;
|
||||
return { total, pct };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -124,7 +133,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
<!-- Filter tabs -->
|
||||
<div class="filter-tabs">
|
||||
<button
|
||||
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
|
||||
v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
|
||||
:key="tab"
|
||||
:class="['tab-btn', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -163,6 +172,18 @@ function truncate(text: string | null, max = 120): string {
|
||||
</p>
|
||||
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
|
||||
|
||||
<!-- Overall completion bar -->
|
||||
<div
|
||||
v-if="overallPct(project).total > 0"
|
||||
class="overall-bar-row"
|
||||
:title="`${project.summary?.task_counts.done ?? 0} of ${overallPct(project).total} tasks complete`"
|
||||
>
|
||||
<div class="overall-bar-track">
|
||||
<div class="overall-bar-fill" :style="{ width: overallPct(project).pct + '%' }"></div>
|
||||
</div>
|
||||
<span class="overall-bar-pct">{{ overallPct(project).pct }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Milestone progress bars -->
|
||||
<div
|
||||
v-if="project.summary?.milestone_summary?.length"
|
||||
@@ -262,18 +283,21 @@ function truncate(text: string | null, max = 120): string {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Moss action-primary per Hybrid — list-view utility action,
|
||||
not a brand moment. Empty-state .empty-action below keeps accent. */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
@@ -300,7 +324,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
.tab-btn.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-msg,
|
||||
@@ -315,7 +339,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
|
||||
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
||||
.empty-title { font-size: 1rem; font-weight: 600; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--color-primary); color: #fff; }
|
||||
@@ -368,7 +392,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
}
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -377,7 +401,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
@@ -405,7 +429,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -416,6 +440,34 @@ function truncate(text: string | null, max = 120): string {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.overall-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.overall-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--color-border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.overall-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.overall-bar-pct {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.milestone-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -496,7 +548,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
}
|
||||
.modal-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.required {
|
||||
|
||||
@@ -5,6 +5,16 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Clock,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
interface Milestone {
|
||||
id: number;
|
||||
@@ -23,7 +33,7 @@ interface Project {
|
||||
title: string;
|
||||
description: string | null;
|
||||
goal: string | null;
|
||||
status: "active" | "completed" | "archived";
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
auto_summary: string | null;
|
||||
permission?: string;
|
||||
@@ -118,6 +128,14 @@ function toggleMilestoneCollapse(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function autoCollapseCompleted(msList: Milestone[]) {
|
||||
for (const ms of msList) {
|
||||
if (ms.total > 0 && ms.completed === ms.total) {
|
||||
collapsedMilestones.value.add(ms.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -130,6 +148,7 @@ async function loadProject() {
|
||||
editStatus.value = data.status;
|
||||
editDirty.value = false;
|
||||
milestones.value = data.summary?.milestone_summary ?? [];
|
||||
autoCollapseCompleted(milestones.value);
|
||||
} catch {
|
||||
error.value = "Failed to load project.";
|
||||
} finally {
|
||||
@@ -143,6 +162,7 @@ async function loadMilestones() {
|
||||
`/api/projects/${projectId.value}/milestones`
|
||||
);
|
||||
milestones.value = data.milestones;
|
||||
autoCollapseCompleted(milestones.value);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
@@ -320,7 +340,7 @@ async function confirmDelete() {
|
||||
<router-link to="/projects" class="btn-back">← Projects</router-link>
|
||||
<div class="page-header-actions">
|
||||
<router-link v-if="project" :to="`/workspace/${project.id}`" class="btn-workspace">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
|
||||
<LayoutGrid :size="16" />
|
||||
Workspace
|
||||
</router-link>
|
||||
<button v-if="project" class="btn-share" @click="showShare = true">Share</button>
|
||||
@@ -349,7 +369,7 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
|
||||
<p v-if="project.summary?.last_activity" class="project-activity">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
|
||||
<Clock :size="16" />
|
||||
Active {{ relativeTime(project.summary.last_activity) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -372,7 +392,7 @@ async function confirmDelete() {
|
||||
<span class="stat-label">done</span>
|
||||
</div>
|
||||
<div class="stat-chip stat-notes">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true" style="opacity:0.6"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
||||
<FileText :size="16" style="opacity:0.6" />
|
||||
<span class="stat-val">{{ project.summary.note_count }}</span>
|
||||
<span class="stat-label">notes</span>
|
||||
</div>
|
||||
@@ -395,6 +415,7 @@ async function confirmDelete() {
|
||||
<label class="edit-label">Status</label>
|
||||
<select v-model="editStatus" class="edit-select">
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
@@ -409,7 +430,7 @@ async function confirmDelete() {
|
||||
<div class="tab-bar">
|
||||
<button :class="['tab-btn', { active: activeTab === 'tasks' }]" @click="activeTab = 'tasks'">
|
||||
Tasks
|
||||
<span v-if="project.summary" class="tab-count">{{ project.summary.task_counts.todo + project.summary.task_counts.in_progress + project.summary.task_counts.done }}</span>
|
||||
<span v-if="project.summary" class="tab-count">{{ (project.summary.task_counts.todo ?? 0) + (project.summary.task_counts.in_progress ?? 0) + (project.summary.task_counts.done ?? 0) }}</span>
|
||||
</button>
|
||||
<button :class="['tab-btn', { active: activeTab === 'notes' }]" @click="activeTab = 'notes'">
|
||||
Notes
|
||||
@@ -452,7 +473,8 @@ async function confirmDelete() {
|
||||
@click="group.milestone && renamingMilestoneId !== group.milestone.id && toggleMilestoneCollapse(group.milestone.id)"
|
||||
>
|
||||
<span class="ms-chevron" v-if="group.milestone">
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor"><path :d="collapsedMilestones.has(group.milestone.id) ? 'M8 5l8 7-8 7V5z' : 'M5 8l7 8 7-8H5z'"/></svg>
|
||||
<ChevronRight v-if="collapsedMilestones.has(group.milestone.id)" :size="16" />
|
||||
<ChevronDown v-else :size="16" />
|
||||
</span>
|
||||
<template v-if="group.milestone && renamingMilestoneId === group.milestone.id">
|
||||
<input
|
||||
@@ -474,10 +496,10 @@ async function confirmDelete() {
|
||||
<span class="ms-pct">{{ group.milestone.pct }}%</span>
|
||||
<div class="ms-actions" @click.stop>
|
||||
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
<Pencil :size="16" />
|
||||
</button>
|
||||
<button class="ms-action-btn ms-action-delete" title="Delete" @click="deletingMilestone = group.milestone">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -543,7 +565,7 @@ async function confirmDelete() {
|
||||
title="Mark as Done"
|
||||
:disabled="advancingTaskId === task.id"
|
||||
@click="advanceTaskStatus(task, $event)"
|
||||
>✓</button>
|
||||
><Check :size="16" /></button>
|
||||
</div>
|
||||
</router-link>
|
||||
<p v-if="!group.tasks.filter(t => t.status === 'in_progress').length" class="col-empty">No tasks</p>
|
||||
@@ -587,7 +609,7 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<template v-else>
|
||||
<router-link v-for="note in notes" :key="note.id" :to="`/notes/${note.id}`" class="note-row">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="note-icon" aria-hidden="true"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
||||
<FileText class="note-icon" :size="16" />
|
||||
<span class="note-title">{{ note.title || "Untitled" }}</span>
|
||||
<span class="note-date">{{ relativeTime(note.updated_at) }}</span>
|
||||
</router-link>
|
||||
@@ -671,48 +693,54 @@ async function confirmDelete() {
|
||||
}
|
||||
.btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
/* Open Workspace: brand-moment CTA — keep accent gradient. Workspace is
|
||||
the project's "central feature moment" — entering the focused workspace
|
||||
is a Scribe-flavored action, not a plain operation. */
|
||||
.btn-workspace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-workspace:hover { box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42); opacity: 0.95; color: #fff; }
|
||||
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
|
||||
|
||||
/* Share: Bronze action-secondary — alternate path */
|
||||
.btn-share {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Delete project: Oxblood action-destructive ghost — outline form since
|
||||
the actual confirm modal carries the filled destructive treatment */
|
||||
.btn-danger-outline {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover { border-color: var(--color-danger, #e74c3c); color: var(--color-danger, #e74c3c); }
|
||||
.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; }
|
||||
|
||||
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||
|
||||
@@ -728,7 +756,7 @@ async function confirmDelete() {
|
||||
.project-title-input {
|
||||
flex: 1;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
@@ -740,11 +768,11 @@ async function confirmDelete() {
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.project-title-input:focus { border-bottom-color: var(--color-primary); }
|
||||
.project-title-input::placeholder { color: var(--color-text-muted); font-weight: 400; font-style: italic; }
|
||||
.project-title-input::placeholder { color: var(--color-text-muted); font-weight: 400; }
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.18rem 0.55rem;
|
||||
@@ -752,6 +780,7 @@ async function confirmDelete() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
|
||||
.status-paused { background: color-mix(in srgb, var(--color-warning) 14%, transparent); color: var(--color-warning); border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent); }
|
||||
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
|
||||
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
|
||||
|
||||
@@ -787,7 +816,7 @@ async function confirmDelete() {
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid;
|
||||
}
|
||||
.stat-val { font-weight: 700; font-size: 0.9rem; }
|
||||
.stat-val { font-weight: 500; font-size: 0.9rem; }
|
||||
.stat-label { color: inherit; opacity: 0.8; }
|
||||
|
||||
.stat-dot {
|
||||
@@ -828,7 +857,7 @@ async function confirmDelete() {
|
||||
.panel-heading {
|
||||
margin: 0 0 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -836,7 +865,7 @@ async function confirmDelete() {
|
||||
.edit-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.edit-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -855,21 +884,21 @@ async function confirmDelete() {
|
||||
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--color-primary); }
|
||||
.edit-textarea { resize: vertical; }
|
||||
|
||||
/* Save panel: Moss action-primary per Hybrid rule */
|
||||
.btn-save-panel {
|
||||
padding: 0.45rem 0.9rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); opacity: 0.95; }
|
||||
.btn-save-panel:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* ── Content area ────────────────────────────────────────────── */
|
||||
@@ -896,10 +925,10 @@ async function confirmDelete() {
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--color-primary); }
|
||||
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 600; }
|
||||
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 500; }
|
||||
.tab-count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
@@ -941,27 +970,32 @@ async function confirmDelete() {
|
||||
font-family: inherit;
|
||||
}
|
||||
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
|
||||
.btn-ms-confirm {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-ms-cancel {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* ── Milestone group ─────────────────────────────────────────── */
|
||||
.milestone-group {
|
||||
@@ -984,7 +1018,7 @@ async function confirmDelete() {
|
||||
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
||||
|
||||
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.ms-name { font-weight: 600; 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 {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
@@ -993,7 +1027,7 @@ async function confirmDelete() {
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.4rem;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ms-progress-track {
|
||||
width: 72px;
|
||||
@@ -1009,7 +1043,7 @@ async function confirmDelete() {
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.ms-pct { font-size: 0.72rem; color: var(--color-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 600; }
|
||||
.ms-pct { font-size: 0.72rem; color: var(--color-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 500; }
|
||||
|
||||
.ms-actions { display: flex; gap: 0.15rem; margin-left: 0.2rem; opacity: 0; transition: opacity 0.15s; }
|
||||
.milestone-header:hover .ms-actions { opacity: 1; }
|
||||
@@ -1038,7 +1072,7 @@ async function confirmDelete() {
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -1069,7 +1103,7 @@ async function confirmDelete() {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -1084,7 +1118,7 @@ async function confirmDelete() {
|
||||
padding: 0.05rem 0.4rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.col-add-btn {
|
||||
display: inline-flex;
|
||||
@@ -1230,8 +1264,8 @@ async function confirmDelete() {
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-danger, #e74c3c); border-color: var(--color-danger, #e74c3c); color: #fff; }
|
||||
.modal-btn-danger:hover { opacity: 0.9; }
|
||||
.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); }
|
||||
|
||||
/* ── Skeleton ────────────────────────────────────────────────── */
|
||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||
|
||||
+526
-596
File diff suppressed because it is too large
Load Diff
@@ -248,7 +248,6 @@ onMounted(async () => {
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.88rem;
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
|
||||
@@ -25,6 +25,7 @@ import DiffView from "@/components/DiffView.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||||
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
|
||||
import { Trash2 } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -362,7 +363,7 @@ async function confirmDelete() {
|
||||
await store.deleteTask(taskId.value);
|
||||
dirty.value = false;
|
||||
toast.show("Task deleted");
|
||||
router.push("/tasks");
|
||||
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
|
||||
} catch {
|
||||
toast.show("Failed to delete task", "error");
|
||||
}
|
||||
@@ -410,11 +411,13 @@ useEditorGuards(dirty, save);
|
||||
<main class="editor-page task-editor-page">
|
||||
<div class="editor-header">
|
||||
<div class="toolbar">
|
||||
<router-link to="/tasks" class="btn-back">← Tasks</router-link>
|
||||
<router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back">← {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
|
||||
<button class="btn-save" @click="save" :disabled="saving">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||||
<Trash2 :size="16" /> Delete
|
||||
</button>
|
||||
<WordCount :body="body" />
|
||||
</div>
|
||||
<input
|
||||
@@ -737,12 +740,19 @@ useEditorGuards(dirty, save);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* .task-main is a flex column; without flex-shrink: 0, long body content
|
||||
gets squeezed back to min-height and overflows visibly on top of siblings. */
|
||||
.body-editor-wrap,
|
||||
.body-log {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.body-editor-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.body-log {
|
||||
/* TaskLogSection already has its own border/bg */
|
||||
:deep(.preview-pane) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Right sidebar: metadata fields */
|
||||
@@ -811,7 +821,7 @@ useEditorGuards(dirty, save);
|
||||
}
|
||||
.subtasks-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -889,7 +899,6 @@ useEditorGuards(dirty, save);
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.stream-preview {
|
||||
border: 1px solid var(--color-input-border);
|
||||
@@ -911,7 +920,7 @@ useEditorGuards(dirty, save);
|
||||
}
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
@@ -13,6 +13,7 @@ import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import TableOfContents from "@/components/TableOfContents.vue";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -314,12 +315,12 @@ const subTaskProgress = computed(() => {
|
||||
<h1 class="task-title">{{ store.currentTask.title || "Untitled" }}</h1>
|
||||
<p class="meta">
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
|
||||
<Clock :size="16" />
|
||||
Updated {{ relativeTime(store.currentTask.updated_at) }}
|
||||
</span>
|
||||
<span class="meta-sep" aria-hidden="true">·</span>
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
<Pencil :size="16" />
|
||||
Created {{ relativeTime(store.currentTask.created_at) }}
|
||||
</span>
|
||||
</p>
|
||||
@@ -395,7 +396,7 @@ const subTaskProgress = computed(() => {
|
||||
|
||||
<div v-if="backlinks.length" class="backlinks">
|
||||
<h3 class="backlinks-heading">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
<LinkIcon :size="16" />
|
||||
Backlinks
|
||||
<span class="backlinks-count">{{ backlinks.length }}</span>
|
||||
</h3>
|
||||
@@ -474,55 +475,41 @@ const subTaskProgress = computed(() => {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-edit {
|
||||
/* Edit + Advance: Moss action-primary — both are "operating the software"
|
||||
workflow actions, not brand moments. */
|
||||
.btn-edit,
|
||||
.btn-advance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
opacity: 0.95;
|
||||
.btn-edit:hover,
|
||||
.btn-advance:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-advance {
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-advance:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
@@ -530,21 +517,21 @@ const subTaskProgress = computed(() => {
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.task-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
@@ -579,7 +566,7 @@ const subTaskProgress = computed(() => {
|
||||
}
|
||||
.due-date.overdue {
|
||||
color: var(--color-overdue);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.task-meta-row {
|
||||
display: flex;
|
||||
@@ -617,7 +604,7 @@ const subTaskProgress = computed(() => {
|
||||
.subtasks-title {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.subtasks-progress {
|
||||
font-size: 0.8rem;
|
||||
@@ -717,7 +704,7 @@ const subTaskProgress = computed(() => {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -759,7 +746,7 @@ const subTaskProgress = computed(() => {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import type { Task, TaskStatus, TaskPriority } from "@/types/task";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||
import SearchBar from "@/components/SearchBar.vue";
|
||||
import TaskCard from "@/components/TaskCard.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
|
||||
type ViewMode = "smart" | "grouped";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useTasksStore();
|
||||
|
||||
const searchBarRef = ref<{ focus: () => void } | null>(null);
|
||||
|
||||
function onFocusSearch() {
|
||||
searchBarRef.value?.focus();
|
||||
}
|
||||
|
||||
const _storedMode = localStorage.getItem("fabled-tasks-view-mode");
|
||||
const viewMode = ref<ViewMode>(_storedMode === "grouped" ? "grouped" : "smart");
|
||||
|
||||
function setViewMode(mode: ViewMode) {
|
||||
viewMode.value = mode;
|
||||
localStorage.setItem("fabled-tasks-view-mode", mode);
|
||||
store.limit = mode === "grouped" ? 100 : 200;
|
||||
store.offset = 0;
|
||||
store.refresh();
|
||||
}
|
||||
|
||||
// Keyboard nav disabled — smart sections span multiple containers
|
||||
useListKeyboardNavigation(
|
||||
computed(() => store.tasks),
|
||||
(task) => router.push(`/tasks/${task.id}`),
|
||||
".kb-active-item",
|
||||
computed(() => false),
|
||||
);
|
||||
|
||||
// Project map for group labels and task breadcrumbs
|
||||
const projectMap = ref<Map<number, string>>(new Map());
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const data = await apiGet<{ projects: { id: number; title: string }[] }>("/api/projects");
|
||||
projectMap.value = new Map(data.projects.map((p) => [p.id, p.title]));
|
||||
} catch {
|
||||
// non-fatal — labels just won't show
|
||||
}
|
||||
}
|
||||
|
||||
// Grouped tasks: [{projectId, title, tasks[]}]
|
||||
interface TaskGroup {
|
||||
projectId: number | null;
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
interface TaskSection {
|
||||
key: string;
|
||||
label: string;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
const smartSections = computed<TaskSection[]>(() => {
|
||||
if (viewMode.value !== "smart") return [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const weekEnd = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const overdue: Task[] = [], dueToday: Task[] = [], thisWeek: Task[] = [],
|
||||
upcoming: Task[] = [], noDueDate: Task[] = [], done: Task[] = [];
|
||||
for (const task of store.tasks) {
|
||||
if (task.status === "done") { done.push(task); continue; }
|
||||
if (!task.due_date) { noDueDate.push(task); continue; }
|
||||
if (task.due_date < today) { overdue.push(task); continue; }
|
||||
if (task.due_date === today) { dueToday.push(task); continue; }
|
||||
if (task.due_date <= weekEnd) { thisWeek.push(task); continue; }
|
||||
upcoming.push(task);
|
||||
}
|
||||
const sections: TaskSection[] = [];
|
||||
if (overdue.length) sections.push({ key: "overdue", label: "Overdue", tasks: overdue });
|
||||
if (dueToday.length) sections.push({ key: "today", label: "Due Today", tasks: dueToday });
|
||||
if (thisWeek.length) sections.push({ key: "week", label: "This Week", tasks: thisWeek });
|
||||
if (upcoming.length) sections.push({ key: "upcoming", label: "Upcoming", tasks: upcoming });
|
||||
if (noDueDate.length) sections.push({ key: "no-date", label: "No Due Date", tasks: noDueDate });
|
||||
if (done.length) sections.push({ key: "done", label: "Completed", tasks: done });
|
||||
return sections;
|
||||
});
|
||||
|
||||
const groupedTasks = computed<TaskGroup[]>(() => {
|
||||
if (viewMode.value !== "grouped") return [];
|
||||
const map = new Map<number | null, Task[]>();
|
||||
for (const task of store.tasks) {
|
||||
const key = task.project_id ?? null;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(task);
|
||||
}
|
||||
const groups: TaskGroup[] = [];
|
||||
// Named projects first (sorted by title), then "No Project"
|
||||
for (const [id, tasks] of map) {
|
||||
if (id !== null) {
|
||||
groups.push({
|
||||
projectId: id,
|
||||
title: projectMap.value.get(id) ?? `Project #${id}`,
|
||||
tasks,
|
||||
});
|
||||
}
|
||||
}
|
||||
groups.sort((a, b) => a.title.localeCompare(b.title));
|
||||
if (map.has(null)) {
|
||||
groups.push({ projectId: null, title: "No Project", tasks: map.get(null)! });
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const tag = route.query.tag;
|
||||
if (tag) {
|
||||
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
|
||||
store.activeTagFilters = tags;
|
||||
}
|
||||
if (route.query.status) {
|
||||
const qs = route.query.status;
|
||||
store.statusFilter = (Array.isArray(qs) ? qs : [qs]).filter(Boolean) as TaskStatus[];
|
||||
}
|
||||
store.limit = viewMode.value === "grouped" ? 100 : 200;
|
||||
collapsedGroups.value.add("done");
|
||||
await Promise.all([store.refresh(), loadProjects()]);
|
||||
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("shortcut:focus-search", onFocusSearch);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.tag,
|
||||
(tag) => {
|
||||
if (tag) {
|
||||
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
|
||||
store.activeTagFilters = tags;
|
||||
store.offset = 0;
|
||||
store.refresh();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function onSearch(q: string) {
|
||||
store.setSearch(q);
|
||||
}
|
||||
|
||||
function toggleStatusChip(value: TaskStatus) {
|
||||
const current = [...store.statusFilter];
|
||||
const idx = current.indexOf(value);
|
||||
if (idx === -1) current.push(value);
|
||||
else current.splice(idx, 1);
|
||||
store.setStatusFilter(current);
|
||||
}
|
||||
|
||||
function togglePriorityChip(value: TaskPriority) {
|
||||
const current = [...store.priorityFilter];
|
||||
const idx = current.indexOf(value);
|
||||
if (idx === -1) current.push(value);
|
||||
else current.splice(idx, 1);
|
||||
store.setPriorityFilter(current);
|
||||
}
|
||||
|
||||
function onTagClick(tag: string) {
|
||||
store.addTagFilter(tag);
|
||||
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
|
||||
}
|
||||
|
||||
function onTagDismiss(tag: string) {
|
||||
store.removeTagFilter(tag);
|
||||
const newQuery = { ...route.query };
|
||||
if (store.activeTagFilters.length > 0) {
|
||||
newQuery.tag = store.activeTagFilters;
|
||||
} else {
|
||||
delete newQuery.tag;
|
||||
}
|
||||
router.replace({ query: newQuery });
|
||||
}
|
||||
|
||||
function onSortChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
store.setSort(value, store.sortOrder);
|
||||
}
|
||||
|
||||
function toggleOrder() {
|
||||
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
|
||||
}
|
||||
|
||||
function onStatusToggle(id: number, status: TaskStatus) {
|
||||
store.patchStatus(id, status);
|
||||
}
|
||||
|
||||
// Collapse state for grouped sections
|
||||
const collapsedGroups = ref<Set<string>>(new Set());
|
||||
function toggleGroup(key: string) {
|
||||
if (collapsedGroups.value.has(key)) {
|
||||
collapsedGroups.value.delete(key);
|
||||
} else {
|
||||
collapsedGroups.value.add(key);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="tasks-list">
|
||||
<div class="header">
|
||||
<h1>Tasks</h1>
|
||||
<router-link to="/tasks/new" class="btn-new">+ New Task</router-link>
|
||||
</div>
|
||||
<SearchBar ref="searchBarRef" @search="onSearch" />
|
||||
|
||||
<div class="controls">
|
||||
<div class="filter-controls">
|
||||
<div class="filter-chip-group">
|
||||
<button v-for="s in (['todo', 'in_progress', 'done', 'cancelled'] as TaskStatus[])" :key="s"
|
||||
:class="['filter-chip', { active: store.statusFilter.includes(s) }]"
|
||||
@click="toggleStatusChip(s)">
|
||||
{{ { todo: 'Todo', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled' }[s] }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-chip-group">
|
||||
<button v-for="p in (['low', 'medium', 'high'] as TaskPriority[])" :key="p"
|
||||
:class="['filter-chip', { active: store.priorityFilter.includes(p) }]"
|
||||
@click="togglePriorityChip(p)">
|
||||
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-controls">
|
||||
<div class="sort-controls">
|
||||
<select :value="store.sortField" @change="onSortChange" class="sort-select">
|
||||
<option value="updated_at">Updated</option>
|
||||
<option value="created_at">Created</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="due_date">Due Date</option>
|
||||
<option value="priority">Priority</option>
|
||||
</select>
|
||||
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
|
||||
{{ store.sortOrder === "asc" ? "↑" : "↓" }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- View mode toggle -->
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
:class="['toggle-btn', { active: viewMode === 'smart' }]"
|
||||
title="Smart sections (by due date)"
|
||||
@click="setViewMode('smart')"
|
||||
>☰</button>
|
||||
<button
|
||||
:class="['toggle-btn', { active: viewMode === 'grouped' }]"
|
||||
title="Group by project"
|
||||
@click="setViewMode('grouped')"
|
||||
>⊟</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.activeTagFilters.length" class="active-filters">
|
||||
<span class="filter-label">Filtering by:</span>
|
||||
<TagPill
|
||||
v-for="tag in store.activeTagFilters"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
dismissible
|
||||
@dismiss="onTagDismiss"
|
||||
/>
|
||||
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="task-list-skeleton">
|
||||
<div class="skeleton-row" v-for="i in 6" :key="i"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.tasks.length === 0" class="empty-state">
|
||||
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter.length || store.priorityFilter.length">
|
||||
<p class="empty-title">No tasks match your filters</p>
|
||||
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="empty-state-rich">
|
||||
<div class="empty-icon">✓</div>
|
||||
<p class="empty-title">No tasks yet</p>
|
||||
<p class="empty-sub">Create your first task to start tracking work</p>
|
||||
<router-link to="/tasks/new" class="empty-action">New task →</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Grouped view -->
|
||||
<template v-else-if="viewMode === 'grouped'">
|
||||
<div
|
||||
v-for="group in groupedTasks"
|
||||
:key="group.projectId ?? 'none'"
|
||||
class="task-group"
|
||||
>
|
||||
<button
|
||||
class="group-header"
|
||||
@click="toggleGroup(String(group.projectId))"
|
||||
>
|
||||
<span class="group-chevron">{{ collapsedGroups.has(String(group.projectId)) ? '▶' : '▼' }}</span>
|
||||
<span class="group-title">{{ group.title }}</span>
|
||||
<span class="group-count">{{ group.tasks.length }}</span>
|
||||
<router-link
|
||||
v-if="group.projectId"
|
||||
:to="`/projects/${group.projectId}`"
|
||||
class="group-open-link"
|
||||
@click.stop
|
||||
>Open project →</router-link>
|
||||
</button>
|
||||
<div v-if="!collapsedGroups.has(String(group.projectId))" class="group-tasks">
|
||||
<TaskCard
|
||||
v-for="task in group.tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
compact
|
||||
@tag-click="onTagClick"
|
||||
@status-toggle="onStatusToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Smart sections view (by due date) -->
|
||||
<template v-else>
|
||||
<div v-for="section in smartSections" :key="section.key" class="task-section">
|
||||
<button class="section-header" @click="toggleGroup(section.key)">
|
||||
<span :class="['section-dot', `dot-${section.key}`]"></span>
|
||||
<span class="section-label">{{ section.label }}</span>
|
||||
<span class="section-count">{{ section.tasks.length }}</span>
|
||||
<span class="section-chevron">{{ collapsedGroups.has(section.key) ? "▶" : "▼" }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedGroups.has(section.key)" class="section-tasks">
|
||||
<TaskCard
|
||||
v-for="task in section.tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
compact
|
||||
:project-title="task.project_id ? (projectMap.get(task.project_id) ?? undefined) : undefined"
|
||||
@tag-click="onTagClick"
|
||||
@status-toggle="onStatusToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasks-list {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.btn-new {
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-new:hover {
|
||||
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.filter-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.filter-chip {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-input-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.filter-chip.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.right-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.filter-select,
|
||||
.sort-select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sort-order {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.toggle-btn {
|
||||
padding: 0.3rem 0.55rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.active-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.clear-filters {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-danger);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.task-list-skeleton {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.skeleton-row {
|
||||
height: 44px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Smart sections */
|
||||
.task-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0.3rem 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.section-header:hover .section-label {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.section-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-overdue { background: var(--color-danger, #e74c3c); }
|
||||
.dot-today { background: var(--color-primary); }
|
||||
.dot-week { background: #f59e0b; }
|
||||
.dot-upcoming { background: var(--color-text-secondary); }
|
||||
.dot-no-date { background: var(--color-text-muted); }
|
||||
.dot-done { background: var(--color-status-done, #22c55e); }
|
||||
.section-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.section-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
.section-chevron {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.section-tasks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Grouped view */
|
||||
.task-group {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0.3rem 0 0.3rem 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.group-header:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.group-chevron {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.group-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex: 1;
|
||||
}
|
||||
.group-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
.group-open-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.group-open-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.group-tasks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.empty-subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-cta {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.empty-state-rich {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.empty-state-rich .empty-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.empty-action {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.empty-action:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kb-active-item {
|
||||
outline: 2px solid var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
@@ -477,7 +477,6 @@ function formatDate(iso: string): string {
|
||||
.you-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.btn-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
|
||||
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
|
||||
import WorkspaceChatWidget from "@/components/WorkspaceChatWidget.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const chatStore = useChatStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
const projectId = computed(() => Number(route.params.projectId));
|
||||
@@ -21,140 +19,62 @@ interface Project {
|
||||
}
|
||||
|
||||
const project = ref<Project | null>(null);
|
||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
||||
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
||||
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
|
||||
const activeNoteId = ref<number | null>(null);
|
||||
let workspaceConvId: number | null = null;
|
||||
let isNewConv = false;
|
||||
|
||||
function _storageKey(pid: number) {
|
||||
return `workspace_conv_${pid}`;
|
||||
}
|
||||
|
||||
// Panel collapse state — persisted per project
|
||||
// Panel collapse state — persisted per project (tasks + notes only; chat is now a widget)
|
||||
function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
|
||||
|
||||
function _loadPanelState(pid: number) {
|
||||
try {
|
||||
const raw = localStorage.getItem(_panelKey(pid));
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw);
|
||||
// Ensure at least one panel is open after restore
|
||||
if (!saved.tasks && !saved.chat && !saved.notes) saved.chat = true;
|
||||
return saved as { tasks: boolean; chat: boolean; notes: boolean };
|
||||
const saved = JSON.parse(raw) as Partial<{ tasks: boolean; notes: boolean }>;
|
||||
const tasks = saved.tasks ?? true;
|
||||
const notes = saved.notes ?? true;
|
||||
// Guard: at least one panel must be open
|
||||
if (!tasks && !notes) return { tasks: true, notes: true };
|
||||
return { tasks, notes };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { tasks: true, chat: true, notes: true };
|
||||
return { tasks: true, notes: true };
|
||||
}
|
||||
|
||||
const panelOpen = ref({ tasks: true, chat: true, notes: true });
|
||||
const panelOpen = ref<{ tasks: boolean; notes: boolean }>({ tasks: true, notes: true });
|
||||
|
||||
const gridColumns = computed(() => {
|
||||
return [
|
||||
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px",
|
||||
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px",
|
||||
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
|
||||
panelOpen.value.tasks ? "minmax(0, 0.85fr)" : "0px",
|
||||
panelOpen.value.notes ? "minmax(0, 2.15fr)" : "0px",
|
||||
].join(" ");
|
||||
});
|
||||
|
||||
// SSE watcher — auto-load notes/tasks when tool calls succeed
|
||||
const processedCount = ref(0);
|
||||
|
||||
watch(
|
||||
() => chatStore.streamingToolCalls,
|
||||
(calls) => {
|
||||
for (let i = processedCount.value; i < calls.length; i++) {
|
||||
const tc = calls[i];
|
||||
if (
|
||||
["create_note", "update_note"].includes(tc.function) &&
|
||||
tc.status === "success" &&
|
||||
tc.result?.data?.id
|
||||
) {
|
||||
activeNoteId.value = tc.result.data.id as number;
|
||||
noteEditorRef.value?.reload();
|
||||
}
|
||||
if (
|
||||
["create_task", "update_task", "create_milestone", "update_milestone"].includes(tc.function) &&
|
||||
tc.status === "success"
|
||||
) {
|
||||
taskPanelRef.value?.reload();
|
||||
}
|
||||
}
|
||||
processedCount.value = calls.length;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => chatStore.streaming,
|
||||
(s) => {
|
||||
if (!s) processedCount.value = 0;
|
||||
}
|
||||
);
|
||||
|
||||
function togglePanel(panel: keyof typeof panelOpen.value) {
|
||||
const open = panelOpen.value;
|
||||
const openCount = [open.tasks, open.chat, open.notes].filter(Boolean).length;
|
||||
const openCount = [open.tasks, open.notes].filter(Boolean).length;
|
||||
if (open[panel] && openCount <= 1) return;
|
||||
panelOpen.value[panel] = !panelOpen.value[panel];
|
||||
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
chatPanelRef.value?.prefill(text);
|
||||
function onNoteChanged(noteId: number) {
|
||||
activeNoteId.value = noteId;
|
||||
noteEditorRef.value?.reload();
|
||||
}
|
||||
|
||||
function onTaskChanged() {
|
||||
taskPanelRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Restore panel state
|
||||
panelOpen.value = _loadPanelState(projectId.value);
|
||||
|
||||
// Load project info
|
||||
try {
|
||||
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
|
||||
} catch {
|
||||
toast.show("Failed to load project", "error");
|
||||
}
|
||||
|
||||
const key = _storageKey(projectId.value);
|
||||
const storedId = localStorage.getItem(key);
|
||||
|
||||
if (storedId) {
|
||||
// Try to reuse the existing workspace conversation
|
||||
const existingId = Number(storedId);
|
||||
try {
|
||||
await chatStore.fetchConversation(existingId);
|
||||
workspaceConvId = existingId;
|
||||
isNewConv = false;
|
||||
chatStore.reconnectIfGenerating(existingId);
|
||||
} catch {
|
||||
// Conversation was deleted — create a fresh one
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (workspaceConvId === null) {
|
||||
// Create a new workspace conversation and persist its ID
|
||||
const title = project.value ? `${project.value.title} — Workspace` : "Workspace";
|
||||
const conv = await chatStore.createConversation(title);
|
||||
workspaceConvId = conv.id;
|
||||
isNewConv = true;
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
localStorage.setItem(key, String(conv.id));
|
||||
}
|
||||
|
||||
nextTick(() => chatPanelRef.value?.focus());
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
// Only delete if we created a brand-new conversation this session and it's still empty
|
||||
if (workspaceConvId !== null && isNewConv) {
|
||||
const conv = chatStore.conversations.find((c) => c.id === workspaceConvId);
|
||||
if (conv && conv.message_count === 0) {
|
||||
await chatStore.deleteConversation(workspaceConvId);
|
||||
localStorage.removeItem(_storageKey(projectId.value));
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -174,13 +94,6 @@ onUnmounted(async () => {
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
:class="['panel-toggle', { active: panelOpen.chat }]"
|
||||
title="Toggle Chat panel"
|
||||
@click="togglePanel('chat')"
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
:class="['panel-toggle', { active: panelOpen.notes }]"
|
||||
title="Toggle Notes panel"
|
||||
@@ -191,9 +104,8 @@ onUnmounted(async () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Three-panel body -->
|
||||
<!-- Two-panel body -->
|
||||
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
|
||||
|
||||
<!-- Left: Tasks -->
|
||||
<div v-show="panelOpen.tasks" class="ws-panel">
|
||||
<Transition name="panel-fade">
|
||||
@@ -207,35 +119,8 @@ onUnmounted(async () => {
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Center: Chat -->
|
||||
<div v-show="panelOpen.chat" class="ws-panel ws-panel-chat">
|
||||
<Transition name="panel-fade">
|
||||
<div v-if="panelOpen.chat" class="panel-inner panel-inner-chat">
|
||||
<!-- Quick chips (shown when conversation is empty) -->
|
||||
<div
|
||||
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
|
||||
class="empty-chat-prompt"
|
||||
>
|
||||
<p class="empty-hint">What would you like to work on?</p>
|
||||
<div class="quick-chips">
|
||||
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
|
||||
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
|
||||
<button class="quick-chip" @click="prefill('Add tasks for ')">✓ Add tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChatPanel
|
||||
ref="chatPanelRef"
|
||||
variant="full"
|
||||
:projectId="projectId"
|
||||
placeholder="Message the agent… (Enter to send)"
|
||||
class="ws-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Right: Note Editor -->
|
||||
<div v-show="panelOpen.notes" class="ws-panel">
|
||||
<div v-show="panelOpen.notes" class="ws-panel ws-panel-notes">
|
||||
<Transition name="panel-fade">
|
||||
<div v-if="panelOpen.notes" class="panel-inner">
|
||||
<WorkspaceNoteEditor
|
||||
@@ -247,13 +132,21 @@ onUnmounted(async () => {
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Floating chat widget -->
|
||||
<WorkspaceChatWidget
|
||||
v-if="project"
|
||||
:project-id="projectId"
|
||||
@note-changed="onNoteChanged"
|
||||
@task-changed="onTaskChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -261,7 +154,6 @@ onUnmounted(async () => {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.ws-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -291,7 +183,6 @@ onUnmounted(async () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ws-panel-toggles {
|
||||
@@ -316,7 +207,6 @@ onUnmounted(async () => {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Three-panel layout */
|
||||
.ws-body {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
@@ -329,6 +219,10 @@ onUnmounted(async () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ws-panel-notes {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -343,60 +237,4 @@ onUnmounted(async () => {
|
||||
.panel-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Chat panel */
|
||||
.ws-panel-chat {
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-inner-chat {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ws-chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-chat-prompt {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.empty-hint {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.quick-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.quick-chip {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.quick-chip:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
}
|
||||
</style>
|
||||
|
||||
+31
-1
@@ -1,9 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
// @ricky0123/vad-web ships ONNX + audio-worklet assets and depends on
|
||||
// onnxruntime-web's WASM binaries. Copy them into the served root so
|
||||
// MicVAD can load them at runtime via `baseAssetPath: "/"`.
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "node_modules/@ricky0123/vad-web/dist/*.onnx",
|
||||
dest: "",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
{
|
||||
src: "node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js",
|
||||
dest: "",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
{
|
||||
src: "node_modules/onnxruntime-web/dist/*.wasm",
|
||||
dest: "",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
{
|
||||
src: "node_modules/onnxruntime-web/dist/*.mjs",
|
||||
dest: "",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
# Runner base image for Forgejo act_runner job containers.
|
||||
# Pre-installs Python 3.12 and Node 20 so the test job skips the
|
||||
# ~3-4 minute deadsnakes PPA install on every run.
|
||||
# Pre-installs Python 3.12, Node 22, uv, and ruff so workflows skip
|
||||
# lengthy runtime installs on every run.
|
||||
#
|
||||
# Build and push (one-time setup, re-run if this file changes):
|
||||
# Build and push (re-run when this file changes):
|
||||
# docker build -f infra/Dockerfile.runner-base \
|
||||
# -t git.fabledsword.com/bvandeusen/runner-base:py3.12-node22 .
|
||||
# docker push git.fabledsword.com/bvandeusen/runner-base:py3.12-node22
|
||||
# -t git.fabledsword.com/bvandeusen/runner-base:ci-runner .
|
||||
# docker push git.fabledsword.com/bvandeusen/runner-base:ci-runner
|
||||
#
|
||||
# Then redeploy the act_runner stack in Portainer to pick up the new label.
|
||||
# Then redeploy the act_runner stack in Portainer to pick up the new image.
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=UTC
|
||||
|
||||
# Split into separate RUN steps so each pushed layer is smaller.
|
||||
# One combined layer would be ~280MB and exceeds the nginx upload timeout.
|
||||
# Split into separate RUN steps so each pushed layer is smaller — one
|
||||
# combined layer exceeds the nginx upload timeout on the self-hosted
|
||||
# Forgejo registry.
|
||||
|
||||
# Python 3.12 ships in Ubuntu 24.04 main repos — no PPA needed.
|
||||
# Python 3.12 (ships in Ubuntu 24.04 main repos) + core tools
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -y -qq \
|
||||
python3.12 python3.12-dev python3.12-venv python3-pip \
|
||||
git curl ca-certificates gnupg && \
|
||||
python3.12 python3.12-dev python3.12-venv \
|
||||
git curl ca-certificates gnupg jq tzdata && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node 22 LTS via NodeSource
|
||||
@@ -33,3 +35,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -y -qq docker.io && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# uv — fast Python package installer/resolver (~10x faster than pip).
|
||||
# Used by test jobs instead of pip for venv creation + dep installs.
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env INSTALLER_NO_MODIFY_PATH=1 sh && \
|
||||
mv /root/.local/bin/uv /usr/local/bin/ && \
|
||||
mv /root/.local/bin/uvx /usr/local/bin/
|
||||
|
||||
# ruff — Python linter. Baked in so the lint job is just
|
||||
# `ruff check src/` with zero install overhead.
|
||||
RUN curl -LsSf https://astral.sh/ruff/install.sh | env INSTALLER_NO_MODIFY_PATH=1 sh && \
|
||||
mv /root/.local/bin/ruff /usr/local/bin/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="/home/bvandeusen/Nextcloud/Projects/fabledassistant"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
input=$(cat)
|
||||
command=$(echo "$input" | python3 -c "
|
||||
|
||||
+23
-25
@@ -11,8 +11,8 @@ from fabledassistant.config import Config
|
||||
from fabledassistant.routes.admin import admin_bp
|
||||
from fabledassistant.routes.api import api
|
||||
from fabledassistant.routes.auth import auth_bp
|
||||
from fabledassistant.routes.briefing import briefing_bp
|
||||
from fabledassistant.routes.chat import chat_bp
|
||||
from fabledassistant.routes.journal import journal_bp
|
||||
from fabledassistant.routes.export import export_bp
|
||||
from fabledassistant.routes.notes import notes_bp
|
||||
from fabledassistant.routes.images import images_bp
|
||||
@@ -75,8 +75,8 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(briefing_bp)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(journal_bp)
|
||||
app.register_blueprint(export_bp)
|
||||
app.register_blueprint(images_bp)
|
||||
app.register_blueprint(milestones_bp)
|
||||
@@ -143,10 +143,14 @@ def create_app() -> Quart:
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
|
||||
"connect-src 'self'; font-src 'self' data:; object-src 'none'; "
|
||||
"base-uri 'self'; worker-src 'self';"
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self'; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; worker-src 'self' blob:;"
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -169,11 +173,12 @@ def create_app() -> Quart:
|
||||
|
||||
async def _warm_model(model: str) -> None:
|
||||
"""Warm an already-installed model into VRAM (no pull)."""
|
||||
from fabledassistant.services.llm import keep_alive_for
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/generate",
|
||||
json={"model": model, "prompt": "", "keep_alive": "2h"},
|
||||
json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)},
|
||||
)
|
||||
logger.info("Warmed model '%s' into VRAM", model)
|
||||
except Exception:
|
||||
@@ -187,14 +192,17 @@ def create_app() -> Quart:
|
||||
The num_ctx must match what real requests will use so Ollama doesn't reload.
|
||||
"""
|
||||
try:
|
||||
from fabledassistant.services.llm import build_context, pick_num_ctx
|
||||
from fabledassistant.services.llm import build_context, keep_alive_for, pick_num_ctx
|
||||
from fabledassistant.services.tools import get_tools_for_user
|
||||
messages, _ = await build_context(
|
||||
user_id=user_id,
|
||||
history=[],
|
||||
current_note_id=None,
|
||||
user_message=" ",
|
||||
)
|
||||
num_ctx = pick_num_ctx(messages)
|
||||
# Include tool schemas so num_ctx matches real chat requests.
|
||||
tools = await get_tools_for_user(user_id)
|
||||
num_ctx = pick_num_ctx(messages, tools=tools)
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/chat",
|
||||
@@ -203,7 +211,7 @@ def create_app() -> Quart:
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"num_predict": 1, "num_ctx": num_ctx},
|
||||
"keep_alive": "2h",
|
||||
"keep_alive": keep_alive_for(model),
|
||||
},
|
||||
)
|
||||
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
|
||||
@@ -312,22 +320,12 @@ def create_app() -> Quart:
|
||||
await backfill_project_summaries()
|
||||
except Exception:
|
||||
logger.warning("Project summary backfill failed", exc_info=True)
|
||||
try:
|
||||
from fabledassistant.services.embeddings import backfill_rss_item_embeddings
|
||||
await backfill_rss_item_embeddings()
|
||||
except Exception:
|
||||
logger.warning("RSS embedding backfill failed", exc_info=True)
|
||||
try:
|
||||
from fabledassistant.services.embeddings import backfill_rss_article_content
|
||||
await backfill_rss_article_content()
|
||||
except Exception:
|
||||
logger.warning("RSS article content backfill failed", exc_info=True)
|
||||
|
||||
asyncio.create_task(_delayed_backfill())
|
||||
|
||||
# Start briefing scheduler
|
||||
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
|
||||
await start_briefing_scheduler(asyncio.get_running_loop())
|
||||
# Start journal scheduler (per-user daily prep generation)
|
||||
from fabledassistant.services.journal_scheduler import start_journal_scheduler
|
||||
start_journal_scheduler(asyncio.get_running_loop())
|
||||
|
||||
# Start event scheduler (reminders + CalDAV pull sync)
|
||||
from fabledassistant.services.event_scheduler import start_event_scheduler
|
||||
@@ -341,8 +339,8 @@ def create_app() -> Quart:
|
||||
|
||||
@app.after_serving
|
||||
async def shutdown():
|
||||
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
|
||||
stop_briefing_scheduler()
|
||||
from fabledassistant.services.journal_scheduler import stop_journal_scheduler
|
||||
stop_journal_scheduler()
|
||||
from fabledassistant.services.event_scheduler import stop_event_scheduler
|
||||
stop_event_scheduler()
|
||||
|
||||
|
||||
@@ -25,9 +25,15 @@ class Config:
|
||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
|
||||
# Lightweight model for background tasks (title generation, tag suggestions,
|
||||
# project summaries, RSS classification). Using a separate model keeps the
|
||||
# project summaries). Using a separate model keeps the
|
||||
# main model's KV cache intact between user messages, enabling prefix cache hits.
|
||||
OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "qwen2.5:3b")
|
||||
OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "gemma3:4b")
|
||||
# Ollama keep_alive — how long a model stays resident in VRAM after its last
|
||||
# request. Main model gets a longer window since it's used interactively;
|
||||
# the background model is called sporadically and doesn't need to camp VRAM.
|
||||
# Format matches Ollama's duration strings: "30m", "10m", "1h", "0s", "-1" (forever).
|
||||
OLLAMA_KEEP_ALIVE_MAIN: str = os.environ.get("OLLAMA_KEEP_ALIVE_MAIN", "30m")
|
||||
OLLAMA_KEEP_ALIVE_BACKGROUND: str = os.environ.get("OLLAMA_KEEP_ALIVE_BACKGROUND", "10m")
|
||||
# KV cache context window for generation. Keep this as small as practical —
|
||||
# a larger context forces more KV cache into CPU RAM, drastically slowing prefill.
|
||||
# 16384 covers ~30+ message conversations with our system prompt comfortably.
|
||||
@@ -46,7 +52,7 @@ class Config:
|
||||
SMTP_USERNAME: str = os.environ.get("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD: str = _read_secret("SMTP_PASSWORD", "SMTP_PASSWORD_FILE", "")
|
||||
SMTP_FROM_ADDRESS: str = os.environ.get("SMTP_FROM_ADDRESS", "")
|
||||
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Assistant")
|
||||
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Scribe")
|
||||
SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes")
|
||||
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")
|
||||
|
||||
@@ -38,8 +38,14 @@ from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
|
||||
from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401
|
||||
from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
||||
from fabledassistant.models.notification import Notification # noqa: E402, F401
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401
|
||||
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
|
||||
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
|
||||
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
|
||||
from fabledassistant.models.rss_item_embedding import RssItemEmbedding # noqa: E402, F401
|
||||
from fabledassistant.models.moment import ( # noqa: E402, F401
|
||||
Moment,
|
||||
MomentEmbedding,
|
||||
moment_people,
|
||||
moment_places,
|
||||
moment_tasks,
|
||||
moment_notes,
|
||||
)
|
||||
|
||||
@@ -18,11 +18,11 @@ class Conversation(Base, TimestampMixin):
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
model: Mapped[str] = mapped_column(Text, default="")
|
||||
# 'chat' (default) or 'briefing'. Briefing conversations are hidden from
|
||||
# the main chat view and managed by the briefing pipeline.
|
||||
# 'chat' (default) or 'journal'. Journal conversations are day-anchored
|
||||
# and rendered by the /journal view; they are hidden from the main chat list.
|
||||
conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat")
|
||||
# For briefing conversations only: the calendar date this briefing covers.
|
||||
briefing_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
|
||||
# For journal conversations only: the calendar date this conversation covers.
|
||||
day_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
|
||||
# NULL = orphan notes only; -1 = all notes; positive int = specific project
|
||||
rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
|
||||
|
||||
@@ -48,7 +48,7 @@ class Conversation(Base, TimestampMixin):
|
||||
"title": self.title,
|
||||
"model": self.model,
|
||||
"conversation_type": self.conversation_type,
|
||||
"briefing_date": self.briefing_date.isoformat() if self.briefing_date else None,
|
||||
"day_date": self.day_date.isoformat() if self.day_date else None,
|
||||
"rag_project_id": self.rag_project_id,
|
||||
"message_count": msg_count,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -20,7 +20,11 @@ class Event(Base):
|
||||
uid: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=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="")
|
||||
@@ -38,7 +42,21 @@ class Event(Base):
|
||||
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,
|
||||
@@ -47,7 +65,8 @@ class Event(Base):
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
|
||||
"end_dt": self.end_dt.isoformat() if self.end_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,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, Table, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
# Junction tables. People, Places, Tasks, and (regular) Notes all live in
|
||||
# the `notes` table — the four junctions are kept separate (rather than one
|
||||
# merged table with a discriminator) so per-kind queries don't require a
|
||||
# filter, and so the schema is explicit about which links are which.
|
||||
moment_people = Table(
|
||||
"moment_people",
|
||||
Base.metadata,
|
||||
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("person_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
moment_places = Table(
|
||||
"moment_places",
|
||||
Base.metadata,
|
||||
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("place_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
moment_tasks = Table(
|
||||
"moment_tasks",
|
||||
Base.metadata,
|
||||
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("task_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
moment_notes = Table(
|
||||
"moment_notes",
|
||||
Base.metadata,
|
||||
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("note_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Moment(Base):
|
||||
"""A small structured extraction from a journal conversation.
|
||||
|
||||
Many per day. Emitted by the LLM via the `record_moment` tool when it
|
||||
notices a meaningful beat. Stored separately from Notes — they are
|
||||
different in kind: Notes are curated artifacts; Moments are ambient
|
||||
trace data with their own embedding index, so notes-RAG and journal-RAG
|
||||
cannot cross-contaminate.
|
||||
"""
|
||||
|
||||
__tablename__ = "moments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
conversation_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("conversations.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
source_message_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
day_date: Mapped[datetime.date] = mapped_column(Date, nullable=False)
|
||||
occurred_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
recorded_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
people = relationship("Note", secondary=moment_people, lazy="selectin", viewonly=True)
|
||||
places = relationship("Note", secondary=moment_places, lazy="selectin", viewonly=True)
|
||||
tasks = relationship("Note", secondary=moment_tasks, lazy="selectin", viewonly=True)
|
||||
notes = relationship("Note", secondary=moment_notes, lazy="selectin", viewonly=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_moments_user_day", "user_id", "day_date"),
|
||||
Index("ix_moments_user_occurred", "user_id", "occurred_at"),
|
||||
)
|
||||
|
||||
def to_dict(self, *, include_links: bool = True) -> dict:
|
||||
result: dict = {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"conversation_id": self.conversation_id,
|
||||
"source_message_id": self.source_message_id,
|
||||
"day_date": self.day_date.isoformat(),
|
||||
"occurred_at": self.occurred_at.isoformat(),
|
||||
"recorded_at": self.recorded_at.isoformat(),
|
||||
"content": self.content,
|
||||
"raw_excerpt": self.raw_excerpt,
|
||||
"tags": list(self.tags or []),
|
||||
"pinned": self.pinned,
|
||||
}
|
||||
if include_links:
|
||||
result["people"] = [{"id": p.id, "title": p.title} for p in (self.people or [])]
|
||||
result["places"] = [{"id": p.id, "title": p.title} for p in (self.places or [])]
|
||||
result["task_ids"] = [t.id for t in (self.tasks or [])]
|
||||
result["note_ids"] = [n.id for n in (self.notes or [])]
|
||||
return result
|
||||
|
||||
|
||||
class MomentEmbedding(Base):
|
||||
"""Embedding vector for a Moment — used by `search_journal` semantic mode.
|
||||
|
||||
Stored separately from `note_embeddings` so notes-RAG and journal-RAG
|
||||
cannot cross-contaminate. This is a hard invariant of the journal design.
|
||||
"""
|
||||
|
||||
__tablename__ = "moment_embeddings"
|
||||
|
||||
moment_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
embedding: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
@@ -1,78 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ARRAY, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class RssFeed(Base):
|
||||
__tablename__ = "rss_feeds"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
||||
url: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
category: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
items: Mapped[list["RssItem"]] = relationship(
|
||||
back_populates="feed", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "url", name="uq_rss_feeds_user_url"),
|
||||
Index("ix_rss_feeds_user_id", "user_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"category": self.category,
|
||||
"last_fetched_at": self.last_fetched_at.isoformat() if self.last_fetched_at else None,
|
||||
}
|
||||
|
||||
|
||||
class RssItem(Base):
|
||||
__tablename__ = "rss_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
feed_id: Mapped[int] = mapped_column(Integer, ForeignKey("rss_feeds.id", ondelete="CASCADE"))
|
||||
guid: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
url: Mapped[str] = mapped_column(Text, default="")
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Truncated to 2000 chars to keep DB size reasonable
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
topics: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||||
)
|
||||
classified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
feed: Mapped["RssFeed"] = relationship(back_populates="items")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("feed_id", "guid", name="uq_rss_items_feed_guid"),
|
||||
Index("ix_rss_items_feed_id", "feed_id"),
|
||||
Index("ix_rss_items_published_at", "published_at"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"feed_id": self.feed_id,
|
||||
"guid": self.guid,
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
"published_at": self.published_at.isoformat() if self.published_at else None,
|
||||
"content": self.content,
|
||||
"topics": self.topics or [],
|
||||
"classified_at": self.classified_at.isoformat() if self.classified_at else None,
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class RssItemEmbedding(Base):
|
||||
"""Stores the embedding vector for an RSS item, used for semantic news search."""
|
||||
|
||||
__tablename__ = "rss_item_embeddings"
|
||||
|
||||
rss_item_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("rss_items.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
embedding: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -1,508 +0,0 @@
|
||||
"""Briefing API: RSS feeds, weather, and briefing configuration."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.auth import login_required
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem
|
||||
from fabledassistant.services import rss as rss_svc
|
||||
from fabledassistant.services import weather as weather_svc
|
||||
from fabledassistant.services.briefing_conversations import (
|
||||
get_or_create_today_conversation,
|
||||
list_briefing_conversations,
|
||||
post_message,
|
||||
)
|
||||
from fabledassistant.services.chat import add_message, get_conversation
|
||||
from fabledassistant.services.generation_buffer import create_buffer, get_buffer
|
||||
from fabledassistant.services.generation_task import run_generation
|
||||
from fabledassistant.services.settings import get_setting, set_settings_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing")
|
||||
|
||||
_REQUIRE = login_required
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/config", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_config():
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
except Exception:
|
||||
config = {}
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@briefing_bp.route("/config", methods=["PUT"])
|
||||
@_REQUIRE
|
||||
async def put_config():
|
||||
data = await request.get_json()
|
||||
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
|
||||
# Live-patch the scheduler using the stored user_timezone.
|
||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||
tz_override = await get_setting(g.user.id, "user_timezone") or None
|
||||
update_user_schedule(g.user.id, data, tz_override=tz_override)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/feeds", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def list_feeds():
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
|
||||
)
|
||||
feeds = list(result.scalars().all())
|
||||
return jsonify([f.to_dict() for f in feeds])
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def add_feed():
|
||||
data = await request.get_json()
|
||||
url = (data.get("url") or "").strip()
|
||||
if not url:
|
||||
return jsonify({"error": "url required"}), 400
|
||||
scheme = url.split("://")[0].lower() if "://" in url else ""
|
||||
if scheme not in ("http", "https"):
|
||||
return jsonify({"error": "Feed URL must use http or https"}), 400
|
||||
from fabledassistant.services.llm import _is_private_url
|
||||
if _is_private_url(url):
|
||||
return jsonify({"error": "Feed URL must not point to an internal address"}), 400
|
||||
category = data.get("category") or None
|
||||
|
||||
async with async_session() as session:
|
||||
# Prevent duplicates
|
||||
existing = await session.execute(
|
||||
select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
return jsonify({"error": "Feed already added"}), 409
|
||||
|
||||
feed = RssFeed(user_id=g.user.id, url=url, title="", category=category)
|
||||
session.add(feed)
|
||||
await session.commit()
|
||||
await session.refresh(feed)
|
||||
feed_id = feed.id
|
||||
|
||||
# Fetch in background to auto-populate title and get initial items
|
||||
asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url))
|
||||
|
||||
return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds/<int:feed_id>", methods=["DELETE"])
|
||||
@_REQUIRE
|
||||
async def delete_feed(feed_id: int):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id)
|
||||
)
|
||||
feed = result.scalars().first()
|
||||
if not feed:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
await session.delete(feed)
|
||||
await session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds/refresh", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def refresh_feeds():
|
||||
results = await rss_svc.refresh_all_feeds(g.user.id)
|
||||
total_new = sum(results.values())
|
||||
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds/recent", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def recent_items():
|
||||
limit = min(int(request.args.get("limit", 20)), 100)
|
||||
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
|
||||
return jsonify({"items": items})
|
||||
|
||||
|
||||
# ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/weather", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_weather():
|
||||
rows = await weather_svc.get_cached_weather_rows(g.user.id)
|
||||
import json as _json
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
_cfg = _json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
temp_unit = _cfg.get("temp_unit", "C")
|
||||
if temp_unit not in ("C", "F"):
|
||||
temp_unit = "C"
|
||||
except Exception:
|
||||
temp_unit = "C"
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@briefing_bp.route("/weather/geocode", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def geocode_location():
|
||||
data = await request.get_json()
|
||||
query = (data.get("query") or "").strip()
|
||||
if not query:
|
||||
return jsonify({"error": "query required"}), 400
|
||||
try:
|
||||
lat, lon, label = await weather_svc.geocode(query)
|
||||
return jsonify({"lat": lat, "lon": lon, "label": label})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
@briefing_bp.route("/weather/refresh", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def refresh_weather():
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else {}
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
locations = config.get("locations", {})
|
||||
refreshed = []
|
||||
for key, loc in locations.items():
|
||||
if not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=g.user.id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
refreshed.append(key)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
|
||||
return jsonify({"refreshed": refreshed})
|
||||
|
||||
|
||||
# ── Briefing Conversations ─────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/conversations", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_conversations():
|
||||
convs = await list_briefing_conversations(g.user.id)
|
||||
return jsonify({"conversations": convs})
|
||||
|
||||
|
||||
@briefing_bp.route("/conversations/today", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_today_conversation():
|
||||
model = await get_setting(g.user.id, "default_model", "")
|
||||
conv = await get_or_create_today_conversation(g.user.id, model)
|
||||
# Load messages
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv.id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = [m.to_dict() for m in result.scalars().all()]
|
||||
data = conv.to_dict()
|
||||
data["messages"] = messages
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@briefing_bp.route("/conversations/<int:conv_id>/messages", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_conversation_messages(conv_id: int):
|
||||
# Verify ownership
|
||||
async with async_session() as session:
|
||||
conv = await session.get(Conversation, conv_id)
|
||||
if not conv or conv.user_id != g.user.id or conv.conversation_type != "briefing":
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
result = await session.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv_id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = [m.to_dict() for m in result.scalars().all()]
|
||||
return jsonify({"messages": messages})
|
||||
|
||||
|
||||
@briefing_bp.route("/trigger", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def manual_trigger():
|
||||
"""Manually trigger a briefing compilation, including a fresh data refresh."""
|
||||
data = await request.get_json() or {}
|
||||
slot = data.get("slot", "compilation")
|
||||
if slot not in ("compilation", "morning", "midday", "afternoon"):
|
||||
return jsonify({"error": "invalid slot"}), 400
|
||||
|
||||
# Refresh external data first (mirrors what the scheduler does)
|
||||
try:
|
||||
from fabledassistant.services.rss import refresh_all_feeds
|
||||
config_raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
await refresh_all_feeds(g.user.id)
|
||||
for key, loc in config.get("locations", {}).items():
|
||||
if loc.get("lat") and loc.get("lon"):
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=g.user.id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Pre-trigger refresh failed for user %d", g.user.id, exc_info=True)
|
||||
|
||||
from fabledassistant.services.briefing_pipeline import run_compilation
|
||||
|
||||
model = await get_setting(g.user.id, "default_model", "")
|
||||
conv = await get_or_create_today_conversation(g.user.id, model)
|
||||
text, metadata = await run_compilation(g.user.id, slot, model)
|
||||
msg = await post_message(conv.id, "assistant", text, metadata=metadata)
|
||||
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
|
||||
|
||||
|
||||
# ── RSS Reactions ──────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/rss-reactions", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def upsert_rss_reaction():
|
||||
"""Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips."""
|
||||
data = await request.get_json()
|
||||
rss_item_id = data.get("rss_item_id")
|
||||
reaction = data.get("reaction")
|
||||
|
||||
if not rss_item_id or reaction not in ("up", "down"):
|
||||
return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400
|
||||
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
async with async_session() as session:
|
||||
# Ownership check: verify item belongs to a feed owned by this user
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT i.id FROM rss_items i
|
||||
JOIN rss_feeds f ON f.id = i.feed_id
|
||||
WHERE i.id = :item_id AND f.user_id = :uid
|
||||
""").bindparams(item_id=rss_item_id, uid=g.user.id)
|
||||
)
|
||||
if result.first() is None:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
# Check existing reaction
|
||||
existing = await session.execute(
|
||||
_text("""
|
||||
SELECT id, reaction FROM rss_item_reactions
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(uid=g.user.id, item_id=rss_item_id)
|
||||
)
|
||||
row = existing.first()
|
||||
|
||||
if row is None:
|
||||
await session.execute(
|
||||
_text("""
|
||||
INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction)
|
||||
VALUES (:uid, :item_id, :reaction)
|
||||
""").bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction)
|
||||
)
|
||||
action = "created"
|
||||
elif row.reaction == reaction:
|
||||
# Toggle off (same reaction clicked again)
|
||||
await session.execute(
|
||||
_text("""
|
||||
DELETE FROM rss_item_reactions
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(uid=g.user.id, item_id=rss_item_id)
|
||||
)
|
||||
action = "removed"
|
||||
else:
|
||||
# Flip to opposite reaction
|
||||
await session.execute(
|
||||
_text("""
|
||||
UPDATE rss_item_reactions SET reaction = :reaction
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id)
|
||||
)
|
||||
action = "updated"
|
||||
|
||||
await session.commit()
|
||||
|
||||
return jsonify({"ok": True, "action": action})
|
||||
|
||||
|
||||
@briefing_bp.route("/rss-reactions/<int:item_id>", methods=["DELETE"])
|
||||
@_REQUIRE
|
||||
async def delete_rss_reaction(item_id: int):
|
||||
"""Explicitly remove a reaction (useful for MCP/external API callers)."""
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
_text("""
|
||||
DELETE FROM rss_item_reactions
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(uid=g.user.id, item_id=item_id)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@briefing_bp.route("/news", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def list_news():
|
||||
"""Return recent RSS articles with optional feed filter and pagination.
|
||||
|
||||
Query params:
|
||||
days — lookback window (default 2, max 90)
|
||||
limit — items per page (default 40, max 100)
|
||||
offset — pagination offset (default 0)
|
||||
feed_id — optional integer filter by feed
|
||||
"""
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
days = min(int(request.args.get("days", 2)), 90)
|
||||
limit = min(int(request.args.get("limit", 40)), 100)
|
||||
offset = max(int(request.args.get("offset", 0)), 0)
|
||||
feed_id = request.args.get("feed_id", type=int)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT
|
||||
i.id, i.title, i.url, i.content, i.published_at,
|
||||
i.topics, f.title AS feed_title,
|
||||
r.reaction
|
||||
FROM rss_items i
|
||||
JOIN rss_feeds f ON f.id = i.feed_id
|
||||
LEFT JOIN rss_item_reactions r
|
||||
ON r.rss_item_id = i.id AND r.user_id = :uid
|
||||
WHERE f.user_id = :uid
|
||||
AND (CAST(:feed_id AS integer) IS NULL OR f.id = CAST(:feed_id AS integer))
|
||||
AND COALESCE(i.published_at, i.fetched_at) >= NOW() - make_interval(days => :days)
|
||||
ORDER BY COALESCE(i.published_at, i.fetched_at) DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""").bindparams(uid=g.user.id, days=days, limit=limit,
|
||||
offset=offset, feed_id=feed_id)
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"url": r["url"],
|
||||
"snippet": (r["content"] or "")[:300],
|
||||
"content": r["content"] or "",
|
||||
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
|
||||
"topics": r["topics"] or [],
|
||||
"source": r["feed_title"],
|
||||
"reaction": r["reaction"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({"items": items, "offset": offset, "limit": limit})
|
||||
|
||||
|
||||
# ── Article Discuss ────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def discuss_article(item_id: int):
|
||||
"""Inject article content as a synthetic tool exchange and trigger generation."""
|
||||
data = await request.get_json() or {}
|
||||
conv_id = data.get("conv_id")
|
||||
if not conv_id:
|
||||
return jsonify({"error": "conv_id required"}), 400
|
||||
|
||||
uid = g.user.id
|
||||
|
||||
# Verify item belongs to user via feed ownership
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem)
|
||||
.join(RssFeed, RssFeed.id == RssItem.feed_id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
item = result.scalars().first()
|
||||
if item is None:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
# Verify conversation belongs to user
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
# Reject if generation already running
|
||||
if get_buffer(conv_id) is not None:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
article_content = await _fetch_full_article(item.url) or item.content or ""
|
||||
|
||||
# Store synthetic assistant message with read_article tool result
|
||||
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 with fresh messages to build history
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
assert conv is not None
|
||||
|
||||
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 ""
|
||||
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title or "",
|
||||
"Please summarize and discuss this article.",
|
||||
think=True,
|
||||
))
|
||||
|
||||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
||||
@@ -350,11 +350,12 @@ async def warm_model_route():
|
||||
return jsonify({"error": "model is required"}), 400
|
||||
|
||||
async def _warm():
|
||||
from fabledassistant.services.llm import keep_alive_for
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/generate",
|
||||
json={"model": model, "prompt": "", "keep_alive": "30m"},
|
||||
json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)},
|
||||
)
|
||||
logger.info("Warmed model %s", model)
|
||||
except Exception as e:
|
||||
@@ -506,50 +507,3 @@ async def delete_model_route():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@chat_bp.route("/from-article/<int:item_id>", methods=["POST"])
|
||||
@login_required
|
||||
async def create_conversation_from_article(item_id: int):
|
||||
"""Create a chat conversation seeded with an RSS article's content."""
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Message
|
||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||
|
||||
uid = get_current_user_id()
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(RssItem, RssFeed.title.label("feed_title"))
|
||||
.join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
if row is None:
|
||||
return jsonify({"error": "Article not found"}), 404
|
||||
|
||||
item, feed_title = row
|
||||
|
||||
conv_title = (item.title or "Article discussion")[:80]
|
||||
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
source = feed_title or "News"
|
||||
content_body = (await _fetch_full_article(item.url) if item.url else None) or (item.content or "").strip()
|
||||
seeded_text = f"**{source}**\n\n**{item.title}**"
|
||||
if content_body:
|
||||
seeded_text += f"\n\n{content_body}"
|
||||
if item.url:
|
||||
seeded_text += f"\n\nSource: {item.url}"
|
||||
|
||||
async with _async_session() as session:
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role="assistant",
|
||||
content=seeded_text,
|
||||
msg_metadata={"rss_item_ids": [item_id]},
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
|
||||
return jsonify({"conversation_id": conv.id}), 201
|
||||
|
||||
@@ -54,19 +54,23 @@ async def create_event():
|
||||
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
event = await events_svc.create_event(
|
||||
user_id=_get_current_user_id(),
|
||||
title=data["title"],
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
all_day=data.get("all_day", False),
|
||||
description=data.get("description", ""),
|
||||
location=data.get("location", ""),
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
reminder_minutes=data.get("reminder_minutes"),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -93,7 +97,7 @@ async def update_event(event_id: int):
|
||||
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"):
|
||||
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"):
|
||||
@@ -106,11 +110,14 @@ async def update_event(event_id: int):
|
||||
fields[dt_field] = _parse_dt(data[dt_field])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
**fields,
|
||||
)
|
||||
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())
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""HTTP endpoints for the Journal feature.
|
||||
|
||||
Includes the conversational journal endpoints (config / today / day / days /
|
||||
trigger-prep / moments) plus the ambient-context surface lifted from the
|
||||
old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss).
|
||||
The ambient endpoints read locations + temp_unit + topic preferences from the
|
||||
``journal_config`` user setting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.models import Conversation, Message, async_session
|
||||
from fabledassistant.services import weather as weather_svc
|
||||
from fabledassistant.services.journal_prep import ensure_daily_prep_message
|
||||
from fabledassistant.services.journal_scheduler import (
|
||||
DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
|
||||
update_user_schedule,
|
||||
)
|
||||
from fabledassistant.services.journal_search import search_journal
|
||||
from fabledassistant.services.moments import delete_moment, update_moment
|
||||
from fabledassistant.services.settings import get_setting, set_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
journal_bp = Blueprint("journal", __name__, url_prefix="/api/journal")
|
||||
|
||||
|
||||
def _resolve_tz(tz_str: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _today_in_tz(tz_str: str, *, day_rollover_hour: int) -> datetime.date:
|
||||
tz = _resolve_tz(tz_str)
|
||||
now = datetime.datetime.now(tz)
|
||||
if now.hour < day_rollover_hour:
|
||||
return (now - datetime.timedelta(days=1)).date()
|
||||
return now.date()
|
||||
|
||||
|
||||
async def _user_timezone(user_id: int) -> str:
|
||||
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
|
||||
|
||||
|
||||
async def _resolve_config(user_id: int) -> dict:
|
||||
raw = await get_setting(user_id, "journal_config", "")
|
||||
config: dict = {}
|
||||
if raw:
|
||||
try:
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(parsed, dict):
|
||||
config = parsed
|
||||
except Exception:
|
||||
logger.warning("Invalid journal_config for user %d", user_id)
|
||||
return {**DEFAULT_JOURNAL_CONFIG, **config}
|
||||
|
||||
|
||||
@journal_bp.get("/config")
|
||||
@login_required
|
||||
async def get_config():
|
||||
user_id = get_current_user_id()
|
||||
return jsonify(await _resolve_config(user_id))
|
||||
|
||||
|
||||
@journal_bp.put("/config")
|
||||
@login_required
|
||||
async def put_config():
|
||||
user_id = get_current_user_id()
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "config must be an object"}), 400
|
||||
await set_setting(user_id, "journal_config", json.dumps(body))
|
||||
await update_user_schedule(user_id)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@journal_bp.get("/today")
|
||||
@login_required
|
||||
async def get_today():
|
||||
user_id = get_current_user_id()
|
||||
config = await _resolve_config(user_id)
|
||||
tz_str = await _user_timezone(user_id)
|
||||
today = _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
|
||||
|
||||
await ensure_daily_prep_message(
|
||||
user_id=user_id, day_date=today, user_timezone=tz_str
|
||||
)
|
||||
return await _day_payload(user_id=user_id, day_date=today)
|
||||
|
||||
|
||||
@journal_bp.get("/day/<iso_date>")
|
||||
@login_required
|
||||
async def get_day(iso_date: str):
|
||||
user_id = get_current_user_id()
|
||||
try:
|
||||
day = datetime.date.fromisoformat(iso_date)
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid date"}), 400
|
||||
return await _day_payload(user_id=user_id, day_date=day)
|
||||
|
||||
|
||||
@journal_bp.get("/days")
|
||||
@login_required
|
||||
async def list_days():
|
||||
user_id = get_current_user_id()
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Conversation.day_date)
|
||||
.where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date.is_not(None),
|
||||
)
|
||||
.order_by(Conversation.day_date.desc())
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return jsonify({"days": [d.isoformat() for d in rows]})
|
||||
|
||||
|
||||
@journal_bp.post("/trigger-prep")
|
||||
@login_required
|
||||
async def trigger_prep():
|
||||
user_id = get_current_user_id()
|
||||
body = await request.get_json(silent=True) or {}
|
||||
iso_date = body.get("date")
|
||||
config = await _resolve_config(user_id)
|
||||
tz_str = await _user_timezone(user_id)
|
||||
day = (
|
||||
datetime.date.fromisoformat(iso_date)
|
||||
if iso_date
|
||||
else _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
|
||||
)
|
||||
msg = await ensure_daily_prep_message(
|
||||
user_id=user_id, day_date=day, user_timezone=tz_str, force=True
|
||||
)
|
||||
return jsonify({"ok": True, "message_id": msg.id})
|
||||
|
||||
|
||||
@journal_bp.get("/moments")
|
||||
@login_required
|
||||
async def list_moments():
|
||||
user_id = get_current_user_id()
|
||||
args = request.args
|
||||
df = args.get("date_from")
|
||||
dt = args.get("date_to")
|
||||
person_id = args.get("person_id", type=int)
|
||||
place_id = args.get("place_id", type=int)
|
||||
tag = args.get("tag")
|
||||
query = args.get("query")
|
||||
pinned_only = args.get("pinned_only", "false").lower() == "true"
|
||||
limit = args.get("limit", default=50, type=int)
|
||||
|
||||
results = await search_journal(
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
person_id=person_id,
|
||||
place_id=place_id,
|
||||
tag=tag,
|
||||
date_from=datetime.date.fromisoformat(df) if df else None,
|
||||
date_to=datetime.date.fromisoformat(dt) if dt else None,
|
||||
limit=limit,
|
||||
)
|
||||
if pinned_only:
|
||||
results = [r for r in results if r.get("pinned")]
|
||||
return jsonify({"moments": results})
|
||||
|
||||
|
||||
@journal_bp.patch("/moments/<int:moment_id>")
|
||||
@login_required
|
||||
async def patch_moment(moment_id: int):
|
||||
user_id = get_current_user_id()
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "body must be an object"}), 400
|
||||
moment = await update_moment(
|
||||
user_id=user_id,
|
||||
moment_id=moment_id,
|
||||
content=body.get("content"),
|
||||
tags=body.get("tags"),
|
||||
pinned=body.get("pinned"),
|
||||
person_ids=body.get("person_ids"),
|
||||
place_ids=body.get("place_ids"),
|
||||
task_ids=body.get("task_ids"),
|
||||
note_ids=body.get("note_ids"),
|
||||
)
|
||||
if moment is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(moment.to_dict())
|
||||
|
||||
|
||||
@journal_bp.delete("/moments/<int:moment_id>")
|
||||
@login_required
|
||||
async def remove_moment(moment_id: int):
|
||||
user_id = get_current_user_id()
|
||||
deleted = await delete_moment(user_id=user_id, moment_id=moment_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────────
|
||||
# Ambient endpoints (lifted from the old briefing surface).
|
||||
# ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _journal_temp_unit(user_id: int) -> str:
|
||||
cfg = await _resolve_config(user_id)
|
||||
unit = cfg.get("temp_unit", "C")
|
||||
return unit if unit in ("C", "F") else "C"
|
||||
|
||||
|
||||
# ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_STALE_THRESHOLD_SECONDS = 4 * 3600 # 4 hours — start refreshing well before the 7-day forecast window slides past today
|
||||
|
||||
|
||||
def _is_stale(cache_row) -> bool:
|
||||
if cache_row is None or cache_row.fetched_at is None:
|
||||
return True
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds()
|
||||
return age > _STALE_THRESHOLD_SECONDS
|
||||
|
||||
|
||||
async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None:
|
||||
"""Best-effort refresh of stale cache rows. Silently no-ops if the user's
|
||||
config has no usable lat/lon for a given location_key."""
|
||||
cfg = await _resolve_config(user_id)
|
||||
locations = cfg.get("locations") or {}
|
||||
for key in stale_keys:
|
||||
loc = locations.get(key)
|
||||
if not loc or not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True)
|
||||
|
||||
|
||||
@journal_bp.get("/weather")
|
||||
@login_required
|
||||
async def get_weather():
|
||||
user_id = get_current_user_id()
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
|
||||
# Kick off a best-effort background refresh for stale rows so the next page
|
||||
# load gets fresh data; we still serve whatever's currently cached now.
|
||||
stale_keys = {row.location_key for row in rows if _is_stale(row)}
|
||||
if stale_keys:
|
||||
asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys))
|
||||
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@journal_bp.get("/weather/current")
|
||||
@login_required
|
||||
async def get_current_weather():
|
||||
"""Live current temperature + conditions for the user's primary location."""
|
||||
user_id = get_current_user_id()
|
||||
cfg = await _resolve_config(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
locations = cfg.get("locations") or {}
|
||||
loc = locations.get("home") or locations.get("work")
|
||||
if not loc or not loc.get("lat") or not loc.get("lon"):
|
||||
return jsonify({"error": "No location configured"}), 404
|
||||
|
||||
current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"])
|
||||
if current is None:
|
||||
return jsonify({"error": "Failed to fetch current conditions"}), 502
|
||||
|
||||
temp = current["temperature"]
|
||||
if temp is not None and temp_unit == "F":
|
||||
temp = temp * 9 / 5 + 32
|
||||
current["temperature"] = round(temp) if temp is not None else None
|
||||
current["temp_unit"] = temp_unit
|
||||
current["location"] = loc.get("label") or "Home"
|
||||
return jsonify(current)
|
||||
|
||||
|
||||
@journal_bp.post("/weather/refresh")
|
||||
@login_required
|
||||
async def refresh_weather():
|
||||
user_id = get_current_user_id()
|
||||
cfg = await _resolve_config(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
for key, loc in (cfg.get("locations") or {}).items():
|
||||
if not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@journal_bp.post("/weather/geocode")
|
||||
@login_required
|
||||
async def geocode_location():
|
||||
data = await request.get_json()
|
||||
query = (data.get("query") or "").strip()
|
||||
if not query:
|
||||
return jsonify({"error": "query required"}), 400
|
||||
try:
|
||||
lat, lon, label = await weather_svc.geocode(query)
|
||||
return jsonify({"lat": lat, "lon": lon, "label": label})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
async def _day_payload(*, user_id: int, day_date: datetime.date):
|
||||
async with async_session() as session:
|
||||
conv_stmt = select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date == day_date,
|
||||
)
|
||||
conv = (await session.execute(conv_stmt)).scalar_one_or_none()
|
||||
if conv is None:
|
||||
return jsonify({
|
||||
"day_date": day_date.isoformat(),
|
||||
"conversation": None,
|
||||
"messages": [],
|
||||
})
|
||||
|
||||
msgs_stmt = (
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv.id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = (await session.execute(msgs_stmt)).scalars().all()
|
||||
return jsonify({
|
||||
"day_date": day_date.isoformat(),
|
||||
"conversation": conv.to_dict(),
|
||||
"messages": [m.to_dict() for m in messages],
|
||||
})
|
||||
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
||||
|
||||
_VALID_TYPES = {"note", "person", "place", "list"}
|
||||
_VALID_TYPES = {"note", "person", "place", "list", "task"}
|
||||
_VALID_SORTS = {"modified", "created", "alpha", "type"}
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user