Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8765959ea | |||
| c33cab7020 | |||
| 36cd08c236 | |||
| 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 |
@@ -57,7 +57,7 @@ permissions:
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
+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
|
||||
|
||||
|
||||
+13
-14
@@ -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 |
|
||||
@@ -227,10 +225,11 @@ 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/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 |
|
||||
@@ -293,8 +292,8 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
|
||||
| `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 |
|
||||
|
||||
@@ -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.*
|
||||
+12
-9
@@ -52,19 +52,22 @@ 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+127
-135
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -582,148 +591,131 @@ 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 = "",
|
||||
async def fable_get_journal_day(iso_date: str) -> dict:
|
||||
"""Fetch a specific day's Journal payload by ISO date.
|
||||
|
||||
Args:
|
||||
iso_date: YYYY-MM-DD format date.
|
||||
|
||||
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 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_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 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:
|
||||
"""Add an RSS or Atom feed to Fable's daily briefing.
|
||||
"""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:
|
||||
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").
|
||||
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 the created feed object including its assigned id.
|
||||
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 briefing.add_rss_feed(
|
||||
return await journal.list_moments(
|
||||
client,
|
||||
url=url,
|
||||
title=title or None,
|
||||
category=category or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_remove_rss_feed(feed_id: int) -> dict:
|
||||
"""Remove an RSS feed from Fable by its ID."""
|
||||
async with FableClient() as client:
|
||||
return await briefing.remove_rss_feed(client, feed_id=feed_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Briefing introspection & control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_briefings() -> dict:
|
||||
"""List the user's briefing conversations, newest first.
|
||||
|
||||
Each briefing has an associated date and conversation id. Use
|
||||
``fable_get_briefing_messages`` or ``fable_get_conversation`` with
|
||||
the id to pull the actual briefing text and tool-call receipts.
|
||||
|
||||
Returns a dict with a ``conversations`` list.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.list_briefing_conversations(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_today_briefing() -> dict:
|
||||
"""Fetch today's briefing conversation, creating it if needed.
|
||||
|
||||
Returns the full conversation object including all messages — the
|
||||
scheduled briefing assistant turns, any tool calls the agentic path
|
||||
made, and any chat replies the user has sent in the briefing thread.
|
||||
|
||||
Use this to inspect what a briefing actually said (and what tool
|
||||
results grounded it) without having to query by id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.get_today_briefing(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_briefing_messages(conversation_id: int) -> dict:
|
||||
"""Fetch all messages for a specific briefing conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The briefing conversation id from fable_list_briefings.
|
||||
|
||||
Returns a dict with a ``messages`` list. Each message includes
|
||||
role, content, tool_calls (with results), and metadata — the
|
||||
metadata carries ``briefing_slot`` tags on agentic briefing turns.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.get_briefing_messages(
|
||||
client, conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_trigger_briefing(slot: str = "compilation") -> dict:
|
||||
"""Manually run a briefing slot for the current user.
|
||||
|
||||
Fires the same data refresh the scheduler does (RSS, weather),
|
||||
runs the agentic briefing pipeline, and writes the result into
|
||||
today's briefing conversation. Use this to test prompt changes
|
||||
without waiting for the next scheduled slot.
|
||||
|
||||
Args:
|
||||
slot: One of ``compilation`` (full morning, default), ``morning``,
|
||||
``midday``, or ``afternoon``.
|
||||
|
||||
Returns a dict with ``conversation_id``, ``message_id``, and ``slot``.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.trigger_briefing(client, slot=slot)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_reset_today_briefing(run_compilation: bool = True) -> dict:
|
||||
"""Wipe today's briefing and (optionally) regenerate from scratch.
|
||||
|
||||
Deletes every message in today's briefing conversation — the
|
||||
conversation row itself is kept so its id stays stable for any
|
||||
open UI sessions. If ``run_compilation`` is True (the default),
|
||||
immediately fires the compilation slot afterward so a fresh
|
||||
briefing lands in place of the deleted content.
|
||||
|
||||
Use this when iterating on briefing prompts or tools and you want
|
||||
to start from a clean slate rather than append another slot update
|
||||
on top of stale output.
|
||||
|
||||
Args:
|
||||
run_compilation: When True, fire ``POST /api/briefing/trigger``
|
||||
for the ``compilation`` slot immediately after wiping.
|
||||
Set False to only wipe without regenerating.
|
||||
|
||||
Returns a dict with ``reset`` (the delete result: deleted count +
|
||||
conversation id) and ``triggered`` (the new message payload, or
|
||||
null if regeneration was skipped).
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.reset_today_briefing(
|
||||
client, run_compilation=run_compilation,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -734,18 +726,18 @@ async def fable_reset_today_briefing(run_compilation: bool = True) -> dict:
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_conversation(conversation_id: int) -> dict:
|
||||
"""Fetch any conversation (chat or briefing) with its full message list.
|
||||
"""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 debugging agentic briefings, inspecting chat history,
|
||||
or verifying that a tool actually ran with the expected arguments.
|
||||
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 briefing.get_conversation(
|
||||
return await journal.get_conversation(
|
||||
client, conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""MCP tools for Fable briefings and RSS feed management."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
# ── RSS feeds ────────────────────────────────────────────────────────────────
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
# ── Briefings ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_briefing_conversations(client: FableClient) -> dict[str, Any]:
|
||||
"""List the user's briefing conversations, newest first."""
|
||||
return await client.get("/api/briefing/conversations")
|
||||
|
||||
|
||||
async def get_today_briefing(client: FableClient) -> dict[str, Any]:
|
||||
"""Fetch today's briefing conversation with all messages."""
|
||||
return await client.get("/api/briefing/conversations/today")
|
||||
|
||||
|
||||
async def get_briefing_messages(
|
||||
client: FableClient,
|
||||
*,
|
||||
conversation_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch messages for a specific briefing conversation."""
|
||||
return await client.get(f"/api/briefing/conversations/{conversation_id}/messages")
|
||||
|
||||
|
||||
async def trigger_briefing(
|
||||
client: FableClient,
|
||||
*,
|
||||
slot: str = "compilation",
|
||||
) -> dict[str, Any]:
|
||||
"""Manually trigger a briefing slot — fires data refresh and runs the pipeline.
|
||||
|
||||
Slot is one of: compilation (full morning), morning, midday, afternoon.
|
||||
"""
|
||||
return await client.post("/api/briefing/trigger", json={"slot": slot})
|
||||
|
||||
|
||||
async def reset_today_briefing(
|
||||
client: FableClient,
|
||||
*,
|
||||
run_compilation: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Delete all messages in today's briefing and optionally regenerate.
|
||||
|
||||
Wipes the messages in today's briefing conversation (keeping the
|
||||
conversation row), then, if ``run_compilation`` is True, fires the
|
||||
compilation slot so a fresh briefing lands in its place.
|
||||
"""
|
||||
reset = await client.post("/api/briefing/reset-today")
|
||||
if not run_compilation:
|
||||
return {"reset": reset, "triggered": None}
|
||||
triggered = await client.post(
|
||||
"/api/briefing/trigger", json={"slot": "compilation"}
|
||||
)
|
||||
return {"reset": reset, "triggered": triggered}
|
||||
|
||||
|
||||
# ── Generic conversations ───────────────────────────────────────────────────
|
||||
|
||||
async def get_conversation(
|
||||
client: FableClient,
|
||||
*,
|
||||
conversation_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch any conversation (chat or briefing) with all messages and tool calls."""
|
||||
return await client.get(f"/api/chat/conversations/{conversation_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.6"
|
||||
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
+807
@@ -24,6 +24,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",
|
||||
@@ -91,6 +92,278 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"cpu": [
|
||||
@@ -106,6 +379,159 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"license": "MIT",
|
||||
@@ -284,6 +710,294 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
"cpu": [
|
||||
@@ -296,6 +1010,90 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.20.1",
|
||||
"license": "MIT",
|
||||
@@ -1908,6 +2706,15 @@
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.469.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.469.0.tgz",
|
||||
"integrity": "sha512-EjOap+vY3xEzCMrnaccDHO4BH3k3Lr+sOyvzRQCaayYxkxKla0w6Jr4h3cHAzA4vMSp63Dcy7vDiGeCPcCY+Gg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -25,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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
+72
-89
@@ -300,141 +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; assistant_message_id?: number; status?: string }> {
|
||||
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;
|
||||
@@ -562,7 +545,7 @@ export interface EventUpdatePayload {
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string;
|
||||
recurrence?: string | null;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+149
-114
@@ -1,131 +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: #7c3aed;
|
||||
--color-danger: #d93025;
|
||||
--color-tag-bg: #ede5ff;
|
||||
--color-tag-text: #6d28d9;
|
||||
/* 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: #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-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(124, 58, 237, 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 rgba(124, 58, 237, 0.4);
|
||||
--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;
|
||||
--color-accent-warm: #b8860b;
|
||||
--color-accent-warm-light: #d4a017;
|
||||
--color-primary-solid: #7c3aed;
|
||||
--color-primary-deep: #5b21b6;
|
||||
/* Reusable brand patterns */
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(124, 58, 237, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(124, 58, 237, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(124, 58, 237, 0.35);
|
||||
--color-primary-faint: rgba(124, 58, 237, 0.08);
|
||||
--color-primary-tint: rgba(124, 58, 237, 0.12);
|
||||
--color-primary-wash: rgba(124, 58, 237, 0.20);
|
||||
}
|
||||
|
||||
[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.22);
|
||||
--color-input-border: rgba(124, 58, 237, 0.35);
|
||||
--color-primary: #a78bfa;
|
||||
--color-danger: #f44336;
|
||||
--color-tag-bg: #2a2a45;
|
||||
--color-tag-text: #c4b5fd;
|
||||
/* 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: #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-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(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;
|
||||
--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(124, 58, 237, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(124, 58, 237, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(124, 58, 237, 0.4);
|
||||
--color-primary-faint: rgba(124, 58, 237, 0.10);
|
||||
--color-primary-tint: rgba(124, 58, 237, 0.14);
|
||||
--color-primary-wash: rgba(124, 58, 237, 0.22);
|
||||
--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;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -138,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,
|
||||
@@ -184,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;
|
||||
@@ -193,11 +228,11 @@ button:not(:disabled):active,
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(124, 58, 237, 0.45);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||
|
||||
@@ -5,20 +5,19 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
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();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
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);
|
||||
|
||||
@@ -76,11 +75,10 @@ router.afterEach(() => {
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<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="/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 v-if="settingsStore.rssEnabled" to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,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">
|
||||
@@ -125,12 +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="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link v-if="settingsStore.rssEnabled" 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>
|
||||
@@ -141,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>
|
||||
@@ -155,7 +151,7 @@ router.afterEach(() => {
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.18);
|
||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
@@ -176,9 +172,8 @@ router.afterEach(() => {
|
||||
}
|
||||
.brand-text {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: #c4b0f0;
|
||||
@@ -223,9 +218,9 @@ router.afterEach(() => {
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary-solid);
|
||||
font-weight: 600;
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
|
||||
font-weight: 500;
|
||||
background: rgba(91, 74, 138, 0.25);
|
||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -247,10 +242,14 @@ router.afterEach(() => {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
.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; }
|
||||
@@ -298,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);
|
||||
|
||||
@@ -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: var(--gradient-cta);
|
||||
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: var(--gradient-cta);
|
||||
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,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import type { ToolCallRecord } from "@/types/chat";
|
||||
|
||||
const props = defineProps<{
|
||||
toolCalls: ToolCallRecord[];
|
||||
}>();
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
function shortName(fn: string): string {
|
||||
return fn
|
||||
.replace(/^(list|get|search|fetch)_/, "")
|
||||
.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
interface Pill {
|
||||
key: string;
|
||||
name: string;
|
||||
count: string | null;
|
||||
state: "ok" | "empty" | "error";
|
||||
}
|
||||
|
||||
const pills = computed<Pill[]>(() =>
|
||||
props.toolCalls.map((tc, i) => {
|
||||
const ok = tc.result?.success !== false && tc.status !== "error";
|
||||
const data = tc.result?.data as Record<string, unknown> | undefined;
|
||||
let count: string | null = null;
|
||||
let state: Pill["state"] = ok ? "ok" : "error";
|
||||
if (ok && data) {
|
||||
const raw =
|
||||
(typeof data.count === "number" && data.count) ||
|
||||
(typeof data.total === "number" && data.total);
|
||||
if (typeof raw === "number") {
|
||||
count = String(raw);
|
||||
if (raw === 0) state = "empty";
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: `${tc.function}-${i}`,
|
||||
name: shortName(tc.function),
|
||||
count,
|
||||
state,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const errorCount = computed(() => pills.value.filter((p) => p.state === "error").length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="briefing-status" :class="{ expanded }">
|
||||
<button class="briefing-status-toggle" @click="expanded = !expanded">
|
||||
<span class="briefing-status-label">Gathered</span>
|
||||
<span
|
||||
v-for="p in pills"
|
||||
:key="p.key"
|
||||
class="briefing-status-pill"
|
||||
:class="`state-${p.state}`"
|
||||
>
|
||||
{{ p.name }}<span v-if="p.count != null" class="pill-count">{{ p.count }}</span>
|
||||
</span>
|
||||
<span v-if="errorCount" class="briefing-status-errcount">{{ errorCount }} failed</span>
|
||||
<span class="briefing-status-chevron" :class="{ open: expanded }">▶</span>
|
||||
</button>
|
||||
<div v-if="expanded" class="briefing-status-detail">
|
||||
<ToolCallCard
|
||||
v-for="(tc, i) in toolCalls"
|
||||
:key="i"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.briefing-status {
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.briefing-status-toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--color-text-muted);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.briefing-status-toggle:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
|
||||
}
|
||||
|
||||
.briefing-status-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.85;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.briefing-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.08rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-card);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.briefing-status-pill.state-empty {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.briefing-status-pill.state-error {
|
||||
border-color: color-mix(in srgb, var(--color-danger, #ef4444) 60%, var(--color-border));
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.pill-count {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.state-error .pill-count {
|
||||
background: color-mix(in srgb, var(--color-danger, #ef4444) 18%, transparent);
|
||||
}
|
||||
|
||||
.briefing-status-errcount {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-danger, #ef4444);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.briefing-status-chevron {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.5;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.briefing-status-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.briefing-status-detail {
|
||||
margin-top: 0.4rem;
|
||||
padding-left: 0.55rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,15 @@ 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 */
|
||||
@@ -277,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
|
||||
@@ -322,12 +329,8 @@ defineExpose({ focus, prefill })
|
||||
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
|
||||
aria-label="Listen and volume settings"
|
||||
>
|
||||
<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>
|
||||
<Volume2 v-if="!tts.speaking.value" :size="16" />
|
||||
<VolumeX v-else :size="16" />
|
||||
</button>
|
||||
<div v-if="speakerPopoverOpen" class="speaker-popover">
|
||||
<button
|
||||
@@ -354,7 +357,7 @@ defineExpose({ focus, prefill })
|
||||
class="speaker-row speaker-row--stop"
|
||||
@click="tts.stop()"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
<Square :size="16" fill="currentColor" />
|
||||
<span>Stop playback</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -371,12 +374,8 @@ defineExpose({ focus, prefill })
|
||||
: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>
|
||||
<Mic v-if="!transcribingVoice" :size="16" />
|
||||
<Loader2 v-else :size="16" class="mic-loader" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -387,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>
|
||||
@@ -484,6 +483,8 @@ defineExpose({ focus, prefill })
|
||||
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
|
||||
@@ -573,7 +574,7 @@ defineExpose({ focus, prefill })
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
|
||||
.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 {
|
||||
|
||||
@@ -3,16 +3,8 @@ import { computed } from "vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import BriefingToolStatusRow from "@/components/BriefingToolStatusRow.vue";
|
||||
import type { Message } from "@/types/chat";
|
||||
|
||||
const SLOT_LABELS: Record<string, string> = {
|
||||
compilation: "Full Briefing",
|
||||
morning: "Morning Update",
|
||||
midday: "Midday Update",
|
||||
afternoon: "Afternoon Update",
|
||||
};
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -51,26 +43,12 @@ function formatMs(ms: number | null | undefined): string {
|
||||
|
||||
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
|
||||
|
||||
const isBriefingIntermediate = computed(
|
||||
() => props.message.role === "assistant" && metadata.value.briefing_intermediate === true,
|
||||
);
|
||||
|
||||
const briefingSlot = computed(() => {
|
||||
const slot = metadata.value.briefing_slot;
|
||||
return typeof slot === "string" ? slot : null;
|
||||
});
|
||||
|
||||
const slotLabel = computed(() => {
|
||||
const slot = briefingSlot.value;
|
||||
if (!slot) return null;
|
||||
return SLOT_LABELS[slot] ?? slot;
|
||||
});
|
||||
|
||||
const isSlotUpdate = computed(
|
||||
() =>
|
||||
!isBriefingIntermediate.value &&
|
||||
briefingSlot.value != null &&
|
||||
briefingSlot.value !== "compilation",
|
||||
// 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[] => {
|
||||
@@ -89,16 +67,11 @@ const timingParts = computed((): string[] => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Briefing intermediate: compact status row, no bubble -->
|
||||
<div v-if="isBriefingIntermediate" class="chat-message role-assistant briefing-intermediate-row">
|
||||
<BriefingToolStatusRow :tool-calls="message.tool_calls ?? []" />
|
||||
</div>
|
||||
<div v-else class="chat-message" :class="[`role-${message.role}`, { 'slot-update': isSlotUpdate }]">
|
||||
<div v-if="!hideMessage" class="chat-message" :class="`role-${message.role}`">
|
||||
<div class="message-group">
|
||||
<div class="message-bubble" :class="{ 'bubble-slot': isSlotUpdate }">
|
||||
<div class="message-bubble">
|
||||
<div class="message-header">
|
||||
<span class="role-label">{{ roleLabel }}</span>
|
||||
<span v-if="slotLabel" class="slot-badge" :class="{ 'slot-badge-update': isSlotUpdate }">{{ slotLabel }}</span>
|
||||
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
|
||||
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
|
||||
Save as Note
|
||||
@@ -182,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;
|
||||
}
|
||||
@@ -194,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;
|
||||
@@ -202,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);
|
||||
@@ -242,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;
|
||||
@@ -319,38 +297,4 @@ details[open] .thinking-summary::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Briefing intermediate row (no bubble) ──────────────── */
|
||||
.briefing-intermediate-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Slot badge (morning/midday/afternoon/full) ─────────── */
|
||||
.slot-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
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);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.slot-badge-update {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* ── Slot update bubbles: softer treatment than compilation ── */
|
||||
.slot-update .message-bubble.bubble-slot {
|
||||
background: transparent;
|
||||
border-left: 2px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.slot-update .message-bubble.bubble-slot .message-content {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
@@ -372,7 +373,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
</router-link>
|
||||
</div>
|
||||
<button class="empty-voice-btn" @click="startVoiceMode" title="Start in voice mode">
|
||||
<span class="voice-icon">🎤</span>
|
||||
<Mic class="voice-icon" :size="16" />
|
||||
<span>Start in voice mode</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -391,7 +392,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
:class="{ collapsed: collapsedSections.has('auto') }"
|
||||
@click="toggleSection('auto')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<ChevronDown class="chev" :size="16" />
|
||||
<span>Auto-included</span>
|
||||
<span class="section-count">{{ autoInjectedNotes.length }}</span>
|
||||
</button>
|
||||
@@ -404,7 +405,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
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">×</button>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -414,7 +415,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
:class="{ collapsed: collapsedSections.has('suggested'), 'context-sidebar-header-gap': autoInjectedNotes.length }"
|
||||
@click="toggleSection('suggested')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<ChevronDown class="chev" :size="16" />
|
||||
<span>Suggested</span>
|
||||
<span class="section-count">{{ suggestedNotes.length }}</span>
|
||||
</button>
|
||||
@@ -436,14 +437,14 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
:class="{ collapsed: collapsedSections.has('included'), 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }"
|
||||
@click="toggleSection('included')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<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">×</button>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -579,7 +580,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
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);
|
||||
@@ -597,7 +598,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
.context-sidebar-header .section-count {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 8px;
|
||||
@@ -621,7 +622,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
}
|
||||
.auto-pill {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
@@ -685,7 +686,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
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 {
|
||||
@@ -703,13 +704,11 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
}
|
||||
|
||||
.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;
|
||||
font-style: italic;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
}
|
||||
|
||||
.chat-empty-state {
|
||||
@@ -724,17 +723,16 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
|
||||
.empty-greeting {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -768,7 +766,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
|
||||
.empty-recent-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
background: rgba(91, 74, 138, 0.05);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@@ -805,9 +803,9 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
|
||||
.empty-voice-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: linear-gradient(135deg, #5B4A8A, #3F3560);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
|
||||
box-shadow: 0 4px 16px rgba(91, 74, 138, 0.35);
|
||||
}
|
||||
|
||||
.empty-voice-btn .voice-icon {
|
||||
@@ -828,7 +826,6 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
.widget-query {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.widget-text {
|
||||
font-size: 0.9rem;
|
||||
@@ -871,7 +868,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
.btn-open-chat.prominent {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-clear-response {
|
||||
background: none;
|
||||
@@ -918,7 +915,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
: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";
|
||||
@@ -36,6 +37,36 @@ const description = ref("");
|
||||
const location = ref("");
|
||||
const color = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const recurrence = ref<string>("");
|
||||
|
||||
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
|
||||
// through to `recurrence`. CalDAV-imported rules with extra parts
|
||||
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
|
||||
// string is shown read-only below the select.
|
||||
const RECURRENCE_PRESETS: Record<string, string> = {
|
||||
none: "",
|
||||
daily: "FREQ=DAILY",
|
||||
weekly: "FREQ=WEEKLY",
|
||||
monthly: "FREQ=MONTHLY",
|
||||
yearly: "FREQ=YEARLY",
|
||||
};
|
||||
|
||||
const recurrencePreset = computed<string>({
|
||||
get() {
|
||||
const r = (recurrence.value || "").trim();
|
||||
if (!r) return "none";
|
||||
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
|
||||
if (val && val === r) return key;
|
||||
}
|
||||
return "custom";
|
||||
},
|
||||
set(key: string) {
|
||||
if (key === "custom") return; // no-op; can't pick custom from dropdown
|
||||
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
|
||||
|
||||
function dateFromIso(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
@@ -114,6 +145,7 @@ function resetForm() {
|
||||
location.value = props.event.location || "";
|
||||
color.value = props.event.color || "";
|
||||
projectId.value = props.event.project_id;
|
||||
recurrence.value = props.event.recurrence || "";
|
||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
||||
} else {
|
||||
@@ -129,6 +161,7 @@ function resetForm() {
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
recurrence.value = "";
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
deleteConfirm.value = false;
|
||||
@@ -176,26 +209,69 @@ 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) {
|
||||
toast.show("Start time is required", "error");
|
||||
return;
|
||||
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))
|
||||
@@ -213,9 +289,9 @@ async function save() {
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || null,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
toast.show("Event updated", "success");
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
@@ -227,9 +303,9 @@ async function save() {
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
toast.show("Event created", "success");
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
@@ -255,23 +331,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 }]"
|
||||
@@ -280,74 +390,73 @@ async function doDelete() {
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Start <span class="required">*</span></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" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
|
||||
<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="so-past-hint">This event is in the past</p>
|
||||
<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</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" :min="startDate" />
|
||||
<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>
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Repeat</label>
|
||||
<select v-model="recurrencePreset" class="form-input">
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
|
||||
</select>
|
||||
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
|
||||
Custom rule: <code>{{ recurrence }}</code>
|
||||
<br />
|
||||
<span class="form-hint">Picking a preset will replace this rule.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="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="#7c3aed" />
|
||||
<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>
|
||||
@@ -355,81 +464,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);
|
||||
@@ -440,18 +578,33 @@ async function doDelete() {
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.so-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.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; }
|
||||
.so-past-hint {
|
||||
.form-past-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recurrence-custom-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.recurrence-custom-hint code {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
@@ -482,67 +635,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: var(--gradient-cta);
|
||||
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 {
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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 = [
|
||||
@@ -81,7 +97,7 @@ const groups = [
|
||||
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(124, 58, 237, 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(124, 58, 237, 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(124, 58, 237, 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(124, 58, 237, 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(124, 58, 237, 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -273,6 +273,5 @@ function hasPrecip(day: ForecastDay): boolean {
|
||||
|
||||
.weather-unavailable {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 }>();
|
||||
|
||||
@@ -179,7 +180,9 @@ defineExpose({ prefill });
|
||||
:class="{ active: historyOpen }"
|
||||
title="History"
|
||||
@click="toggleHistory"
|
||||
>▾</button>
|
||||
>
|
||||
<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
|
||||
@@ -195,11 +198,11 @@ defineExpose({ prefill });
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-sm" title="Restart conversation" @click="restart">↻</button>
|
||||
<button class="btn-icon-sm" title="Restart conversation" @click="restart">
|
||||
<RotateCcw :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" title="Collapse" @click="collapse">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<ChevronDown :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,9 +220,7 @@ defineExpose({ prefill });
|
||||
<!-- 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">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
<ChevronUp :size="16" />
|
||||
</button>
|
||||
<div class="ws-chat-input-row">
|
||||
<ChatInputBar
|
||||
@@ -264,7 +265,7 @@ defineExpose({ prefill });
|
||||
}
|
||||
.ws-chat-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -339,7 +340,7 @@ defineExpose({ prefill });
|
||||
}
|
||||
.ws-chat-history-item.current {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ws-chat-history-title {
|
||||
display: block;
|
||||
|
||||
@@ -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()">
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -729,15 +730,18 @@ 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 {
|
||||
@@ -750,7 +754,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text);
|
||||
padding: 0;
|
||||
@@ -760,7 +764,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.note-title-input:focus { outline: none; }
|
||||
.note-title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
@@ -843,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 */
|
||||
|
||||
@@ -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,7 +48,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: "/notes",
|
||||
redirect: "/",
|
||||
redirect: "/knowledge",
|
||||
},
|
||||
{
|
||||
path: "/notes/new",
|
||||
@@ -117,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",
|
||||
|
||||
@@ -16,10 +16,6 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
() => settings.value.default_model || ""
|
||||
);
|
||||
|
||||
const rssEnabled = computed(
|
||||
() => settings.value.rss_enabled === "true"
|
||||
);
|
||||
|
||||
// Voice status — checked once on login, refreshable from Settings
|
||||
const voiceEnabled = ref(false);
|
||||
const voiceSttReady = ref(false);
|
||||
@@ -66,7 +62,6 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
loading,
|
||||
assistantName,
|
||||
defaultModel,
|
||||
rssEnabled,
|
||||
voiceEnabled,
|
||||
voiceSttReady,
|
||||
voiceTtsReady,
|
||||
|
||||
@@ -1,828 +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 { useSettingsStore } from '@/stores/settings'
|
||||
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,
|
||||
listEvents,
|
||||
type BriefingConversation,
|
||||
type EventEntry,
|
||||
} 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()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// 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
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
const selectedWeatherIdx = ref(0)
|
||||
const tempUnit = ref<string>('C')
|
||||
|
||||
interface CurrentConditions {
|
||||
temperature: number | null;
|
||||
windspeed: number | null;
|
||||
description: string;
|
||||
precip_next_3h: number[];
|
||||
temp_unit: string;
|
||||
location: string;
|
||||
}
|
||||
const currentConditions = ref<CurrentConditions | null>(null)
|
||||
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadCurrentConditions() {
|
||||
try {
|
||||
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
|
||||
// Patch the live temperature into the WeatherCard so it stays fresh
|
||||
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
|
||||
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
|
||||
}
|
||||
} catch { /* silent — endpoint may not have locations configured */ }
|
||||
}
|
||||
|
||||
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 */ }
|
||||
}
|
||||
|
||||
const refreshingWeather = ref(false)
|
||||
async function refreshWeather() {
|
||||
refreshingWeather.value = true
|
||||
try {
|
||||
const data = await apiPost<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather/refresh', {})
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
finally { refreshingWeather.value = false }
|
||||
}
|
||||
|
||||
// Upcoming events (right column, below weather)
|
||||
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 {
|
||||
const now = new Date()
|
||||
const end = new Date(now)
|
||||
end.setDate(end.getDate() + 14)
|
||||
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
|
||||
} 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(),
|
||||
loadCurrentConditions(),
|
||||
loadEvents(),
|
||||
])
|
||||
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
|
||||
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await checkSetup()
|
||||
if (!showWizard.value) {
|
||||
await loadAll()
|
||||
// Poll current conditions every 30 minutes
|
||||
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
|
||||
}
|
||||
})
|
||||
</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: Chat -->
|
||||
<div class="briefing-center">
|
||||
<ChatPanel
|
||||
variant="full"
|
||||
briefingMode
|
||||
:readOnly="!isToday"
|
||||
placeholder="Reply to your briefing…"
|
||||
class="briefing-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Weather + News -->
|
||||
<div class="briefing-right">
|
||||
<!-- Weather section (sticky) -->
|
||||
<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"
|
||||
>↻</button>
|
||||
</div>
|
||||
<WeatherCard
|
||||
:weather="weatherData[selectedWeatherIdx]"
|
||||
:temp-unit="tempUnit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming events -->
|
||||
<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>
|
||||
|
||||
<!-- News section (scrollable) -->
|
||||
<div v-if="settingsStore.rssEnabled" class="news-section">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.briefing-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(320px, 35%);
|
||||
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 (Chat) ─────────────────────────────────────────────────── */
|
||||
|
||||
.briefing-center {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ─── Right column (Weather + News) ──────────────────────────────────────── */
|
||||
|
||||
.briefing-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: 600;
|
||||
}
|
||||
|
||||
/* ─── Upcoming events ─────────────────────────────────────── */
|
||||
.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: 700;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
.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: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
.news-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem 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);
|
||||
}
|
||||
|
||||
/* ── Current conditions (live) ─────────────────────────── */
|
||||
|
||||
.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 1fr auto;
|
||||
}
|
||||
.briefing-center {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
.briefing-right {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</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: var(--gradient-cta);
|
||||
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;
|
||||
@@ -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(124,58,237,0.22);
|
||||
background: rgba(91, 74, 138,0.22);
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
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, #7c3aed);
|
||||
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;
|
||||
@@ -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,7 +746,7 @@ const upcomingGrouped = computed(() => {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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();
|
||||
@@ -314,9 +315,7 @@ onUnmounted(() => {
|
||||
aria-label="List actions"
|
||||
title="List actions"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
|
||||
</svg>
|
||||
<MoreVertical :size="16" />
|
||||
</button>
|
||||
<div v-if="sidebarKebabOpen" class="kebab-menu">
|
||||
<button
|
||||
@@ -339,6 +338,7 @@ onUnmounted(() => {
|
||||
:disabled="bulkDeleting"
|
||||
@click="bulkDelete"
|
||||
>
|
||||
<Trash2 :size="16" />
|
||||
{{ bulkDeleting ? '...' : bulkConfirm ? `Delete ${selectedIds.size}?` : `Delete (${selectedIds.size})` }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -388,11 +388,7 @@ 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>
|
||||
|
||||
@@ -433,7 +429,7 @@ onUnmounted(() => {
|
||||
@click="toggleContextSidebar"
|
||||
:title="contextSidebarOpen ? 'Hide context panel' : 'Show context panel'"
|
||||
>
|
||||
<span class="context-icon">📎</span>
|
||||
<Paperclip class="context-icon" :size="16" />
|
||||
<span class="context-label">Context</span>
|
||||
<span class="context-count-badge">{{ contextCount }}</span>
|
||||
</button>
|
||||
@@ -446,9 +442,7 @@ onUnmounted(() => {
|
||||
aria-label="Conversation actions"
|
||||
title="Conversation actions"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
|
||||
</svg>
|
||||
<MoreVertical :size="16" />
|
||||
</button>
|
||||
<div v-if="headerKebabOpen" class="kebab-menu kebab-menu--right">
|
||||
<button
|
||||
@@ -470,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">
|
||||
@@ -520,7 +510,7 @@ onUnmounted(() => {
|
||||
.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);
|
||||
}
|
||||
|
||||
.sidebar-search-row {
|
||||
@@ -556,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;
|
||||
@@ -591,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);
|
||||
@@ -612,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);
|
||||
@@ -625,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;
|
||||
@@ -704,7 +701,7 @@ onUnmounted(() => {
|
||||
50% {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 0 6px rgba(99, 102, 241, 0.4);
|
||||
box-shadow: 0 0 6px rgba(91, 74, 138, 0.4);
|
||||
}
|
||||
}
|
||||
.scope-dot { font-size: 0.6rem; }
|
||||
@@ -732,7 +729,7 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-option:hover { background: var(--color-bg-secondary); }
|
||||
.scope-option.active { color: var(--color-primary); font-weight: 600; }
|
||||
.scope-option.active { color: var(--color-primary); font-weight: 500; }
|
||||
|
||||
/* Context toggle button (header) */
|
||||
.btn-context-toggle {
|
||||
@@ -759,7 +756,7 @@ onUnmounted(() => {
|
||||
.context-icon { font-size: 0.85rem; line-height: 1; }
|
||||
.context-count-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -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(124, 58, 237, 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;
|
||||
@@ -610,22 +610,22 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
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(124, 58, 237, 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(124, 58, 237, 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, #7c3aed 15%, transparent);
|
||||
color: #7c3aed;
|
||||
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;
|
||||
@@ -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();
|
||||
@@ -410,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>
|
||||
@@ -427,23 +440,23 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
||||
<button @click="createNew('note')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<FileText :size="16" />
|
||||
Note
|
||||
</button>
|
||||
<button @click="createNew('task')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
<CheckSquare :size="16" />
|
||||
Task
|
||||
</button>
|
||||
<button @click="createNew('person')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<User :size="16" />
|
||||
Person
|
||||
</button>
|
||||
<button @click="createNew('place')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<MapPin :size="16" />
|
||||
Place
|
||||
</button>
|
||||
<button @click="createNew('list')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
<List :size="16" />
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
@@ -494,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"
|
||||
@@ -510,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>
|
||||
@@ -631,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">
|
||||
@@ -657,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>
|
||||
|
||||
@@ -721,17 +731,17 @@ onUnmounted(() => {
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
border: 1px solid rgba(124, 58, 237, 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(124, 58, 237, 0.18); }
|
||||
.today-event-chip:hover { background: rgba(91, 74, 138, 0.18); }
|
||||
.chip-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #7c3aed;
|
||||
background: #5B4A8A;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
|
||||
@@ -776,18 +786,16 @@ onUnmounted(() => {
|
||||
content: '· · ·';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: rgba(124, 58, 237, 0.3);
|
||||
color: rgba(91, 74, 138, 0.3);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.4em;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
.filter-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
/* ── New item button ─────────────────────────────────────── */
|
||||
@@ -807,7 +815,7 @@ onUnmounted(() => {
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
|
||||
@@ -892,7 +900,7 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-btn.active .filter-count {
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
background: rgba(91, 74, 138, 0.2);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.filter-tag { font-size: 0.78rem; }
|
||||
@@ -966,7 +974,7 @@ onUnmounted(() => {
|
||||
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
|
||||
.btn-graph.active {
|
||||
background: var(--color-primary-wash);
|
||||
border-color: rgba(124, 58, 237, 0.35);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@@ -999,12 +1007,12 @@ onUnmounted(() => {
|
||||
}
|
||||
.k-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(124, 58, 237, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(124, 58, 237, 0.35);
|
||||
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-specific card DNA */
|
||||
.k-card--note { border-color: rgba(124, 58, 237, 0.20); }
|
||||
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
||||
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
|
||||
.k-card--place { border-color: rgba(245, 158, 11, 0.18); }
|
||||
@@ -1023,7 +1031,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.k-card--note::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #7c3aed, #a78bfa);
|
||||
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
|
||||
}
|
||||
.k-card--task::before {
|
||||
right: 0;
|
||||
@@ -1057,11 +1065,11 @@ 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: #a78bfa; }
|
||||
.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; }
|
||||
@@ -1069,7 +1077,7 @@ onUnmounted(() => {
|
||||
|
||||
.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;
|
||||
@@ -1166,7 +1174,7 @@ onUnmounted(() => {
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
|
||||
|
||||
/* ── Task card ──────────────────────────────────────────── */
|
||||
.k-card-task {
|
||||
@@ -1183,7 +1191,7 @@ onUnmounted(() => {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
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); }
|
||||
@@ -1194,7 +1202,7 @@ onUnmounted(() => {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
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); }
|
||||
@@ -1202,7 +1210,7 @@ onUnmounted(() => {
|
||||
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-accent-warm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.task-overdue {
|
||||
color: var(--color-overdue);
|
||||
@@ -1224,9 +1232,8 @@ onUnmounted(() => {
|
||||
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
|
||||
.empty-narrator {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
color: var(--color-accent-warm);
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -1262,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;
|
||||
}
|
||||
@@ -1325,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();
|
||||
@@ -449,7 +450,9 @@ onUnmounted(() => assist.clearSelection());
|
||||
<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
|
||||
@@ -892,7 +895,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.stream-preview {
|
||||
@@ -1013,7 +1015,7 @@ 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;
|
||||
@@ -1033,8 +1035,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
}
|
||||
.ef-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ef-input {
|
||||
@@ -1067,7 +1068,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
|
||||
@@ -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: var(--gradient-cta);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: var(--glow-cta-hover);
|
||||
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;
|
||||
|
||||
@@ -283,18 +283,21 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
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 {
|
||||
@@ -321,7 +324,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.tab-btn.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-msg,
|
||||
@@ -336,7 +339,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
.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; }
|
||||
@@ -389,7 +392,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
}
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -398,7 +401,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
@@ -426,7 +429,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -462,7 +465,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
flex-shrink: 0;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.milestone-bars {
|
||||
@@ -545,7 +548,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
}
|
||||
.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;
|
||||
@@ -330,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>
|
||||
@@ -359,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>
|
||||
@@ -382,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>
|
||||
@@ -420,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
|
||||
@@ -463,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
|
||||
@@ -485,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>
|
||||
@@ -554,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>
|
||||
@@ -598,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>
|
||||
@@ -682,6 +693,9 @@ 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;
|
||||
@@ -692,38 +706,41 @@ async function confirmDelete() {
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.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; }
|
||||
|
||||
@@ -739,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;
|
||||
@@ -751,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;
|
||||
@@ -763,7 +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-accent-warm) 14%, transparent); color: var(--color-accent-warm); border: 1px solid color-mix(in srgb, var(--color-accent-warm) 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); }
|
||||
|
||||
@@ -799,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 {
|
||||
@@ -840,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);
|
||||
@@ -848,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;
|
||||
@@ -867,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: var(--gradient-cta);
|
||||
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: var(--glow-soft);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(124, 58, 237, 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 ────────────────────────────────────────────── */
|
||||
@@ -908,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;
|
||||
@@ -953,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 {
|
||||
@@ -996,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);
|
||||
@@ -1005,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;
|
||||
@@ -1021,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; }
|
||||
@@ -1050,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;
|
||||
}
|
||||
|
||||
@@ -1081,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);
|
||||
@@ -1096,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;
|
||||
@@ -1242,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; } }
|
||||
|
||||
+335
-589
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();
|
||||
@@ -414,7 +415,9 @@ useEditorGuards(dirty, save);
|
||||
<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: var(--gradient-cta);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: var(--glow-cta-hover);
|
||||
opacity: 0.95;
|
||||
.btn-edit:hover,
|
||||
.btn-advance:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-advance {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--gradient-cta);
|
||||
/* 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -183,7 +183,6 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ws-panel-toggles {
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
@@ -320,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
|
||||
@@ -349,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,7 +25,7 @@ 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", "gemma3:4b")
|
||||
# Ollama keep_alive — how long a model stays resident in VRAM after its last
|
||||
@@ -52,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,96 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ARRAY, BigInteger, 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="")
|
||||
# Full trafilatura-extracted article body, populated lazily on first
|
||||
# discuss-click / enrichment pass. Nullable — most items never get this
|
||||
# cached. Expires naturally with the item (90-day retention).
|
||||
content_full: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Map-reduced conversation-ready context derived from content_full. See
|
||||
# services/article_context.py — populated on first discuss click so
|
||||
# repeat clicks skip both the fetch and the LLM map step.
|
||||
context_prepared: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_fetched_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
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
|
||||
)
|
||||
# Note persisting the first-click discussion summary. Set by the article
|
||||
# discussion pipeline once the seeded chat completes its first assistant
|
||||
# reply; links back into RAG so re-discussing the same article lands the
|
||||
# prior summary in context.
|
||||
discussion_note_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), 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,705 +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})
|
||||
|
||||
|
||||
async def _rss_enabled() -> bool:
|
||||
return (await get_setting(g.user.id, "rss_enabled", "false")).lower() == "true"
|
||||
|
||||
|
||||
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/feeds", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def list_feeds():
|
||||
if not await _rss_enabled():
|
||||
return jsonify([])
|
||||
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():
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"error": "RSS is disabled"}), 403
|
||||
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():
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"feeds_refreshed": 0, "new_items": 0})
|
||||
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():
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"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/current", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_current_weather():
|
||||
"""Return current temperature, conditions, and precipitation for the user's primary location.
|
||||
|
||||
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
|
||||
"""
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
temp_unit = config.get("temp_unit", "C")
|
||||
if temp_unit not in ("C", "F"):
|
||||
temp_unit = "C"
|
||||
locations = config.get("locations", {})
|
||||
except Exception:
|
||||
return jsonify({"error": "No briefing config"}), 404
|
||||
|
||||
# Use home location, fall back to work
|
||||
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
|
||||
|
||||
from fabledassistant.services.weather import fetch_current_conditions
|
||||
current = await fetch_current_conditions(loc["lat"], loc["lon"])
|
||||
if current is None:
|
||||
return jsonify({"error": "Failed to fetch current conditions"}), 502
|
||||
|
||||
# Convert temperature if needed
|
||||
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)
|
||||
|
||||
|
||||
@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 {}
|
||||
temp_unit = config.get("temp_unit", "C")
|
||||
if temp_unit not in ("C", "F"):
|
||||
temp_unit = "C"
|
||||
except Exception:
|
||||
config = {}
|
||||
temp_unit = "C"
|
||||
|
||||
locations = config.get("locations", {})
|
||||
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"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
|
||||
rows = await weather_svc.get_cached_weather_rows(g.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})
|
||||
|
||||
|
||||
# ── 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
|
||||
from fabledassistant.services.briefing_scheduler import _persist_agentic_messages
|
||||
|
||||
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)
|
||||
await _persist_agentic_messages(conv.id, slot, metadata)
|
||||
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
msg = await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
|
||||
|
||||
|
||||
@briefing_bp.route("/reset-today", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def reset_today_briefing():
|
||||
"""Delete all messages in today's briefing conversation.
|
||||
|
||||
The conversation row itself is kept so its id stays stable for any
|
||||
open UI sessions. Intended for "wipe and start fresh" workflows
|
||||
driven from the MCP when iterating on prompts. Pair with
|
||||
``POST /api/briefing/trigger`` to immediately regenerate.
|
||||
"""
|
||||
from sqlalchemy import delete as _delete
|
||||
|
||||
from fabledassistant.services.tz import user_briefing_date
|
||||
|
||||
# User-local briefing day (flips at 4am local), not ``date.today()`` —
|
||||
# see services/tz.py for rationale.
|
||||
today = await user_briefing_date(g.user.id)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == g.user.id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv is None:
|
||||
return jsonify({"deleted": 0, "conversation_id": None})
|
||||
deleted_result = await session.execute(
|
||||
_delete(Message).where(Message.conversation_id == conv.id)
|
||||
)
|
||||
await session.commit()
|
||||
deleted = deleted_result.rowcount or 0
|
||||
|
||||
return jsonify({"deleted": deleted, "conversation_id": conv.id})
|
||||
|
||||
|
||||
# ── 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
|
||||
"""
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"items": [], "total": 0})
|
||||
|
||||
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
|
||||
|
||||
# Shared helper handles the three-layer cache (context_prepared →
|
||||
# content_full → fresh fetch), writes the synthetic read_article tool
|
||||
# exchange and the conversational seed user prompt into the conversation.
|
||||
# The /news from-article route calls the same helper so behavior stays
|
||||
# byte-identical across entry points.
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv_id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
# 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)
|
||||
|
||||
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 "",
|
||||
discuss_prompt,
|
||||
))
|
||||
|
||||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
||||
|
||||
|
||||
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def discuss_topic(topic: str):
|
||||
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
|
||||
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 conversation belongs to user
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
if get_buffer(conv_id) is not None:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
# Find recent articles with this topic (last 2 days)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem)
|
||||
.join(RssFeed, RssFeed.id == RssItem.feed_id)
|
||||
.where(RssFeed.user_id == uid)
|
||||
.where(RssItem.topics.contains([topic]))
|
||||
.order_by(RssItem.published_at.desc().nullslast())
|
||||
.limit(5)
|
||||
)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
if not items:
|
||||
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
|
||||
|
||||
# Fetch full content for each article
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
|
||||
synthetic_tool_calls = []
|
||||
for item in items:
|
||||
content = await _fetch_full_article(item.url) if item.url else None
|
||||
content = content or item.content or ""
|
||||
synthetic_tool_calls.append({
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url or ""},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url or "",
|
||||
"title": item.title or "",
|
||||
"source": "",
|
||||
"content": content[:8000], # cap per article to stay within context
|
||||
"truncated": len(content) > 8000,
|
||||
},
|
||||
})
|
||||
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
|
||||
user_prompt = (
|
||||
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
|
||||
"Don't just summarize each one — draw connections between the sources, "
|
||||
"highlight where they agree or disagree, and share your analysis of what "
|
||||
"this means. Let's have a real discussion about this topic."
|
||||
)
|
||||
await add_message(conv_id, "user", user_prompt)
|
||||
|
||||
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 "",
|
||||
user_prompt,
|
||||
))
|
||||
|
||||
return jsonify({
|
||||
"assistant_message_id": assistant_msg.id,
|
||||
"status": "generating",
|
||||
"article_count": len(items),
|
||||
}), 202
|
||||
@@ -507,81 +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 for article discussion and auto-run.
|
||||
|
||||
Mirrors the briefing ``discuss_article`` route: creates a fresh
|
||||
conversation, stages the shared synthetic read_article exchange + seed
|
||||
prompt, then kicks off generation so the client lands on an in-flight
|
||||
stream. The Flutter and web chat screens reconnect to the running buffer
|
||||
on mount.
|
||||
"""
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
uid = get_current_user_id()
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(RssItem)
|
||||
.join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
item = result.scalars().first()
|
||||
|
||||
if item is None:
|
||||
return jsonify({"error": "Article not found"}), 404
|
||||
|
||||
conv_title = (item.title or "Article discussion")[:80]
|
||||
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv.id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
# Roll back the empty conversation so the user doesn't end up with a
|
||||
# phantom entry in their chat list.
|
||||
try:
|
||||
await delete_conversation(uid, conv.id)
|
||||
except Exception:
|
||||
logger.warning("Failed to clean up empty article conversation %s", conv.id)
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
# Reload conversation so we see the two messages the helper just added.
|
||||
conv = await get_conversation(uid, conv.id)
|
||||
assert conv is not None
|
||||
|
||||
history: list[dict] = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict: 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)
|
||||
|
||||
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 "", discuss_prompt,
|
||||
))
|
||||
|
||||
return jsonify({
|
||||
"conversation_id": conv.id,
|
||||
"assistant_message_id": assistant_msg.id,
|
||||
"status": "generating",
|
||||
}), 202
|
||||
|
||||
@@ -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,414 @@
|
||||
"""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}
|
||||
|
||||
|
||||
def _valid_location_keys(cfg: dict) -> set[str]:
|
||||
"""Keys in ``cfg.locations`` that have a usable lat/lon. Anything else
|
||||
(orphaned cache rows, locations the user typed but didn't geocode) is
|
||||
excluded so it can't render as a fake site in the UI."""
|
||||
locations = cfg.get("locations") or {}
|
||||
return {
|
||||
key for key, loc in locations.items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
}
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
# Trigger a background weather refresh for any newly-saved location with
|
||||
# valid lat/lon. Without this, the cache row for the location doesn't
|
||||
# exist (or stays stale) until the user clicks the manual refresh button,
|
||||
# so the journal weather panel renders empty for newly-entered sites.
|
||||
valid_locs = [
|
||||
(key, loc)
|
||||
for key, loc in (body.get("locations") or {}).items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
]
|
||||
if valid_locs:
|
||||
asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs))
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
async def _refresh_locations_in_background(
|
||||
user_id: int, locations: list[tuple[str, dict]]
|
||||
) -> None:
|
||||
for key, loc in locations:
|
||||
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(
|
||||
"Post-save weather refresh failed for user %d / %s",
|
||||
user_id, key, exc_info=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()
|
||||
cfg = await _resolve_config(user_id)
|
||||
valid_keys = _valid_location_keys(cfg)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
|
||||
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)
|
||||
valid_keys = _valid_location_keys(cfg)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id, valid_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.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],
|
||||
})
|
||||
@@ -94,17 +94,10 @@ async def update_settings_route():
|
||||
if to_save:
|
||||
await set_settings_batch(uid, to_save)
|
||||
|
||||
# When timezone changes, live-patch the briefing scheduler immediately
|
||||
# Live-reschedule the journal daily-prep job when the timezone changes.
|
||||
if "user_timezone" in to_save:
|
||||
import json as _json
|
||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||
config_raw = await get_setting(uid, "briefing_config", "{}")
|
||||
try:
|
||||
config = _json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
except Exception:
|
||||
config = {}
|
||||
if config.get("enabled"):
|
||||
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
|
||||
from fabledassistant.services.journal_scheduler import update_user_schedule as _update_journal_schedule
|
||||
await _update_journal_schedule(uid)
|
||||
|
||||
if "default_model" in to_save and to_save["default_model"]:
|
||||
asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"]))
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
"""Prepare article bodies as conversation-ready context.
|
||||
|
||||
Used by the briefing ``discuss-article`` flow and the ``/news`` discuss button.
|
||||
A raw trafilatura extraction is often too large to drop whole into a chat
|
||||
history without eating the context window, so this module runs a map-reduce
|
||||
step over oversized articles and returns a compact, structured context that
|
||||
still preserves the article's meaning across sections.
|
||||
|
||||
Small articles pass through unchanged — map-reduce only fires when the raw
|
||||
body exceeds CHAR_BUDGET. The output is cached on ``rss_items.context_prepared``
|
||||
by the caller, so repeat discuss-clicks on the same article skip this work
|
||||
entirely.
|
||||
|
||||
The module also owns ``seed_article_discussion``, the shared routine that
|
||||
stages a synthetic ``read_article`` tool exchange plus a conversational seed
|
||||
prompt into a conversation. Both the briefing and ``/news`` entry points call
|
||||
it so the two flows stay byte-identical — the only thing that differs between
|
||||
them is whether the conversation already existed or was freshly created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
from fabledassistant.services.chat import add_message
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ~12k tokens at 4 chars/token. Comfortably under OLLAMA_NUM_CTX=16384
|
||||
# with room left for system prompt, chat history, and the assistant reply.
|
||||
CHAR_BUDGET = 48_000
|
||||
|
||||
# Chunk size for the map step on oversized articles. Overlap preserves
|
||||
# context across paragraph boundaries that happen to land mid-sentence.
|
||||
CHUNK_CHARS = 8_000
|
||||
CHUNK_OVERLAP = 400
|
||||
|
||||
_PARA_SPLIT = re.compile(r"\n\s*\n")
|
||||
|
||||
|
||||
def _chunk_by_paragraph(body: str) -> list[str]:
|
||||
"""Split ``body`` into chunks of up to CHUNK_CHARS, respecting paragraphs.
|
||||
|
||||
Paragraphs longer than CHUNK_CHARS are split mid-paragraph as a last
|
||||
resort. Adjacent chunks share CHUNK_OVERLAP chars of trailing text so
|
||||
a sentence straddling the boundary stays readable on both sides.
|
||||
"""
|
||||
paragraphs = [p.strip() for p in _PARA_SPLIT.split(body) if p.strip()]
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
current_len = 0
|
||||
for para in paragraphs:
|
||||
para_len = len(para)
|
||||
if para_len > CHUNK_CHARS:
|
||||
if current:
|
||||
chunks.append("\n\n".join(current))
|
||||
current, current_len = [], 0
|
||||
for i in range(0, para_len, CHUNK_CHARS - CHUNK_OVERLAP):
|
||||
chunks.append(para[i : i + CHUNK_CHARS])
|
||||
continue
|
||||
if current_len + para_len + 2 > CHUNK_CHARS and current:
|
||||
chunks.append("\n\n".join(current))
|
||||
tail = current[-1][-CHUNK_OVERLAP:] if current else ""
|
||||
current = [tail, para] if tail else [para]
|
||||
current_len = len(tail) + para_len + (2 if tail else 0)
|
||||
else:
|
||||
current.append(para)
|
||||
current_len += para_len + 2
|
||||
if current:
|
||||
chunks.append("\n\n".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
async def _summarize_chunk(title: str, chunk: str, index: int, total: int, model: str) -> str:
|
||||
"""Map-step summary of one article chunk.
|
||||
|
||||
Aims for ~300 words of dense, factual prose — not bullet points — so the
|
||||
downstream chat model can quote from it naturally.
|
||||
"""
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are summarizing one section of a larger article so a downstream "
|
||||
"conversation model can discuss the full article without having to read "
|
||||
"every word.\n\n"
|
||||
"Requirements:\n"
|
||||
"- 250–350 words of dense factual prose\n"
|
||||
"- Preserve specific claims, numbers, names, and quotes\n"
|
||||
"- Do NOT editorialize or add analysis\n"
|
||||
"- Do NOT use bullet points or headings\n"
|
||||
"- Do NOT say 'this section' or 'this article' — write content, not meta"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Article: {title}\n"
|
||||
f"Section {index + 1} of {total}:\n\n{chunk}"
|
||||
),
|
||||
},
|
||||
]
|
||||
try:
|
||||
# Pin num_ctx — same rationale as services/research.py:66. A large
|
||||
# chunk plus system prompt can push well past the default window;
|
||||
# silent truncation here would drop the tail of the chunk without
|
||||
# any error, producing a misleading summary.
|
||||
raw = await generate_completion(
|
||||
messages, model, max_tokens=600, num_ctx=16384
|
||||
)
|
||||
return raw.strip()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Article chunk summary failed for section %d/%d of '%s'",
|
||||
index + 1, total, title, exc_info=True,
|
||||
)
|
||||
# Fall back to the raw chunk truncated to ~1500 chars so the overall
|
||||
# pipeline still delivers something rather than dropping the section.
|
||||
return chunk[:1500]
|
||||
|
||||
|
||||
async def prepare_article_context(
|
||||
title: str,
|
||||
url: str,
|
||||
body: str,
|
||||
model: str,
|
||||
) -> str:
|
||||
"""Return a conversation-ready context block for ``body``.
|
||||
|
||||
- Small article (≤ CHAR_BUDGET): returns ``body`` unchanged.
|
||||
- Oversized article: runs a parallel map step over paragraph-aware
|
||||
chunks and concatenates the summaries under section headers.
|
||||
|
||||
The returned string is what should go into the ``read_article`` synthetic
|
||||
tool-result in chat history. Callers are responsible for caching it to
|
||||
``rss_items.context_prepared``.
|
||||
"""
|
||||
body = body or ""
|
||||
if len(body) <= CHAR_BUDGET:
|
||||
return body
|
||||
|
||||
chunks = _chunk_by_paragraph(body)
|
||||
logger.info(
|
||||
"Article '%s' is %d chars, map-reducing into %d chunks",
|
||||
title, len(body), len(chunks),
|
||||
)
|
||||
|
||||
summaries = await asyncio.gather(
|
||||
*[
|
||||
_summarize_chunk(title, chunk, i, len(chunks), model)
|
||||
for i, chunk in enumerate(chunks)
|
||||
]
|
||||
)
|
||||
|
||||
header = (
|
||||
f"(This article was longer than the chat window could hold verbatim, "
|
||||
f"so the full text was split into {len(chunks)} sections and each was "
|
||||
"summarized below. Each section preserves specific claims, numbers, "
|
||||
"and quotes from the original.)\n\n"
|
||||
)
|
||||
parts = [
|
||||
f"## Section {i + 1}\n\n{summary}"
|
||||
for i, summary in enumerate(summaries)
|
||||
]
|
||||
return header + "\n\n".join(parts)
|
||||
|
||||
|
||||
# Conversational seed prompt for article discussions. Kept here so both the
|
||||
# briefing and /news entry points use the exact same wording. See
|
||||
# feedback_discuss_prompt_style memory: numbered checklists produce
|
||||
# assignment-completion responses; this conversational seed opens a dialogue.
|
||||
ARTICLE_DISCUSS_SEED = (
|
||||
"I want to talk about this article. Start with a substantive summary "
|
||||
"of what it's arguing and the key evidence it uses, then tell me what "
|
||||
"stood out to you or seems worth pushing back on. I'll ask follow-ups "
|
||||
"from there."
|
||||
)
|
||||
|
||||
|
||||
class EmptyArticleError(Exception):
|
||||
"""Raised when an article has no extractable body text.
|
||||
|
||||
Callers (the briefing and /news discuss routes) map this to a 422 so the
|
||||
user sees a clear error instead of a hallucinated summary built from an
|
||||
empty synthetic tool result.
|
||||
"""
|
||||
|
||||
|
||||
async def seed_article_discussion(
|
||||
conv_id: int,
|
||||
item: RssItem,
|
||||
model: str,
|
||||
) -> str:
|
||||
"""Stage the synthetic read_article tool exchange + conversational seed.
|
||||
|
||||
Used by both the briefing ``discuss_article`` route and the ``/news``
|
||||
``from-article`` conversation creator. Handles the three-layer cache
|
||||
(``context_prepared`` → ``content_full`` → fresh fetch) and inserts two
|
||||
messages into ``conv_id``:
|
||||
|
||||
1. An assistant message with a synthetic ``read_article`` tool_call whose
|
||||
``result.content`` carries the prepared article context. The message
|
||||
also carries ``msg_metadata={"rss_item_id": ...}`` so the post-generation
|
||||
hook in ``generation_task.py`` can locate it and persist the first
|
||||
reply as a discussion-summary Note.
|
||||
2. A user message with the shared conversational seed prompt.
|
||||
|
||||
Returns the seed prompt string so callers can pass it to ``run_generation``
|
||||
as ``user_content``.
|
||||
"""
|
||||
# Avoid circulars: rss helper imports article_context indirectly nowhere,
|
||||
# but keep this local for symmetry with the route-level imports it
|
||||
# replaces.
|
||||
from fabledassistant.services.rss import get_or_fetch_full_article
|
||||
|
||||
if item.context_prepared:
|
||||
article_content = item.context_prepared
|
||||
else:
|
||||
raw_body = await get_or_fetch_full_article(item) or item.content or ""
|
||||
if not raw_body.strip():
|
||||
# Hard-fail rather than stage an empty synthetic tool result.
|
||||
# An empty `content` field silently tells the model "the article
|
||||
# has nothing in it" and it confabulates from RAG/history. Better
|
||||
# to surface a clean error to the user.
|
||||
logger.warning(
|
||||
"Article discussion aborted: empty body for rss_item %s (%s)",
|
||||
item.id, item.url,
|
||||
)
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
article_content = await prepare_article_context(
|
||||
item.title or "", item.url, raw_body, model,
|
||||
)
|
||||
if not article_content.strip():
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.context_prepared = article_content
|
||||
await session.commit()
|
||||
|
||||
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,
|
||||
msg_metadata={"rss_item_id": item.id, "article_seed": True},
|
||||
)
|
||||
await add_message(conv_id, "user", ARTICLE_DISCUSS_SEED)
|
||||
return ARTICLE_DISCUSS_SEED
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Generic article-text fetcher.
|
||||
|
||||
Fetches a URL and extracts its main body via trafilatura. The single source
|
||||
of truth for article-content extraction across the codebase — used by the
|
||||
``read_article`` LLM tool and the ``lookup`` tool's web-result enrichment.
|
||||
|
||||
Trafilatura/lxml is NOT safe to call concurrently — running it via
|
||||
``run_in_executor`` from multiple coroutines can trip a libxml2 double-free.
|
||||
Callers must serialize their fetches (await one before starting the next).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fetch_article_text(url: str) -> str | None:
|
||||
"""Return the clean article body for *url*, or None on failure.
|
||||
|
||||
Returns None when the HTTP fetch fails or trafilatura yields nothing
|
||||
useful. Callers should treat None as "no article content available."
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; FabledScribe/1.0; +https://fabledsword.com)",
|
||||
}) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
raw_html = resp.text
|
||||
except Exception:
|
||||
logger.debug("Failed to fetch article URL %s", url)
|
||||
return None
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
import trafilatura
|
||||
text = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: trafilatura.extract(
|
||||
raw_html,
|
||||
include_comments=False,
|
||||
include_tables=True,
|
||||
favor_recall=True,
|
||||
),
|
||||
)
|
||||
return text or None
|
||||
except Exception:
|
||||
logger.debug("trafilatura extraction failed for %s", url, exc_info=True)
|
||||
return None
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Create and manage briefing conversations."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.services.tz import user_briefing_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_or_create_today_conversation(user_id: int, model: str) -> Conversation:
|
||||
"""
|
||||
Return today's briefing conversation, creating it if it doesn't exist.
|
||||
"""
|
||||
# "Today" is the user-local briefing day (flips at 4am local), not
|
||||
# ``date.today()`` — in a UTC container the latter rolls over at
|
||||
# 19:00 NY local and makes the in-progress briefing disappear.
|
||||
today = await user_briefing_date(user_id)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv is not None:
|
||||
return conv
|
||||
|
||||
conv = Conversation(
|
||||
user_id=user_id,
|
||||
title=f"Briefing — {today.isoformat()}",
|
||||
model=model,
|
||||
conversation_type="briefing",
|
||||
briefing_date=today,
|
||||
)
|
||||
session.add(conv)
|
||||
await session.commit()
|
||||
await session.refresh(conv)
|
||||
return conv
|
||||
|
||||
|
||||
async def post_message(
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
metadata: dict | None = None,
|
||||
tool_calls: list | None = None,
|
||||
) -> Message:
|
||||
"""Append a message to a briefing conversation.
|
||||
|
||||
``tool_calls`` is accepted on assistant-role messages so the full
|
||||
agentic briefing sequence (assistant tool-call turns and tool-role
|
||||
results) can be persisted as real conversation rows, keeping the
|
||||
receipts in context on chat follow-ups.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
status="complete",
|
||||
msg_metadata=metadata,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
session.add(msg)
|
||||
# Bump conversation updated_at
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv:
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
async def list_briefing_conversations(user_id: int) -> list[dict]:
|
||||
"""Return briefing conversations newest first."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
).order_by(Conversation.briefing_date.desc().nullslast())
|
||||
.limit(30)
|
||||
)
|
||||
convs = list(result.scalars().all())
|
||||
return [c.to_dict() for c in convs]
|
||||
@@ -1,429 +0,0 @@
|
||||
"""
|
||||
Briefing pipeline: agentic tool-use loop + UI metadata gather.
|
||||
|
||||
Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (4pm)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
|
||||
|
||||
|
||||
# ── External data gather ──────────────────────────────────────────────────────
|
||||
|
||||
async def _gather_external(user_id: int) -> dict:
|
||||
"""Collect RSS items (when enabled) and weather."""
|
||||
from fabledassistant.services.weather import get_cached_weather
|
||||
|
||||
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
rss_items: list = []
|
||||
if rss_on:
|
||||
from fabledassistant.services.rss import get_recent_items
|
||||
try:
|
||||
rss_items = await get_recent_items(user_id, limit=20)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
weather = await get_cached_weather(user_id)
|
||||
except Exception:
|
||||
weather = []
|
||||
|
||||
return {
|
||||
"rss_items": rss_items,
|
||||
"weather": weather,
|
||||
}
|
||||
|
||||
|
||||
# ── Agentic briefing (tool-use loop) ──────────────────────────────────────────
|
||||
|
||||
_BRIEFING_AGENT_MAX_ROUNDS = 8
|
||||
_BRIEFING_AGENT_NUM_CTX = 8192
|
||||
|
||||
|
||||
def _agentic_system_prompt(
|
||||
profile_body: str,
|
||||
slot: str,
|
||||
today_iso: str,
|
||||
tz_name: str,
|
||||
day_from_iso: str,
|
||||
day_to_iso: str,
|
||||
) -> str:
|
||||
"""System prompt for the agentic briefing path.
|
||||
|
||||
Pushes the model to ground every factual claim in a tool result and
|
||||
to be honest when tools return nothing, rather than fabricating
|
||||
content to fill the narrative.
|
||||
|
||||
Pre-computes today's window in the user's local timezone so the
|
||||
model can call date-sensitive tools (list_events, list_tasks filters)
|
||||
without having to do any timezone math itself — eliminating a whole
|
||||
class of "wrong day" bugs.
|
||||
"""
|
||||
tz_block = (
|
||||
f"Today is {today_iso} ({tz_name}). "
|
||||
f"When calling list_events for today, use:\n"
|
||||
f" date_from = {day_from_iso}\n"
|
||||
f" date_to = {day_to_iso}\n"
|
||||
f"These are already the correct local-day boundaries — do not convert "
|
||||
f"them to UTC or any other timezone. For other date ranges, compute in "
|
||||
f"the same timezone.\n\n"
|
||||
)
|
||||
|
||||
if slot == "compilation":
|
||||
base = (
|
||||
"You are the user's personal assistant giving their full morning briefing. "
|
||||
"Weave real data from tool calls into a warm, natural-sounding summary.\n\n"
|
||||
"Tools to call every compilation (skip only if you already know a category is empty):\n"
|
||||
"- list_tasks — what's due today, overdue, or in progress\n"
|
||||
"- list_events — what's on the calendar today\n"
|
||||
"- get_weather — today's forecast\n"
|
||||
"- get_rss_items — recent news/blog items from the user's feeds\n"
|
||||
"- list_projects (optional) — active project context for narrative continuity\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||||
"- If a tool returns nothing (no events today, no overdue tasks, no news items), "
|
||||
"say so honestly. Don't fabricate items to fill space.\n"
|
||||
"- For news, pick one or two items worth mentioning — surface the theme, not a laundry list.\n"
|
||||
"- Write flowing prose. No markdown, no headers, no bullet points.\n"
|
||||
"- Aim for 6 to 10 sentences. Skip topics that have nothing interesting.\n"
|
||||
"- Close on one or two concrete, actionable suggestions.\n\n"
|
||||
)
|
||||
elif slot == "weekly_review":
|
||||
base = (
|
||||
"You are the user's personal assistant delivering a weekly review. "
|
||||
"Use the tools available to see what was accomplished this week, what's still "
|
||||
"overdue, how many notes were captured, and what's coming up in the next seven days. "
|
||||
"Write a reflective recap that celebrates real progress and gently flags what's stuck.\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||||
"- If a category is empty, say so honestly rather than inventing items.\n"
|
||||
"- Write flowing prose. No markdown, no bullet points.\n"
|
||||
"- Aim for 5 to 8 sentences. Reflective and encouraging tone.\n\n"
|
||||
)
|
||||
else: # morning, midday, afternoon check-ins
|
||||
base = (
|
||||
f"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.\n\n"
|
||||
"When checking tasks, call list_tasks at least twice:\n"
|
||||
"- once with status=\"in_progress\" to see anything already being worked on "
|
||||
"(regardless of due date — these can quietly drag past their due dates)\n"
|
||||
"- once filtered by due date for what's coming up or overdue today\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see current state. Never assert facts without tool results.\n"
|
||||
"- If nothing meaningful has changed, say so briefly — don't invent progress.\n"
|
||||
"- 3 to 5 sentences, natural prose, no markdown.\n\n"
|
||||
)
|
||||
|
||||
base = tz_block + base
|
||||
if profile_body:
|
||||
base += f"User profile (tone and preferences):\n{profile_body}\n"
|
||||
return base
|
||||
|
||||
|
||||
def _agentic_user_trigger(slot: str, date_str: str) -> str:
|
||||
"""Seed user-role message that kicks off the agentic run."""
|
||||
labels = {
|
||||
"compilation": "morning briefing",
|
||||
"morning": "morning check-in",
|
||||
"midday": "midday check-in",
|
||||
"afternoon": "afternoon wrap-up",
|
||||
"weekly_review": "weekly review",
|
||||
}
|
||||
label = labels.get(slot, f"{slot} briefing")
|
||||
return f"Generate my {label} for {date_str}."
|
||||
|
||||
|
||||
async def run_agentic_briefing(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str,
|
||||
conv_id: int | None = None,
|
||||
rss_override: list[dict] | None = None,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""
|
||||
Run the agentic briefing loop for a user and slot.
|
||||
|
||||
Uses the chat pipeline's tool-use loop with a curated read-only tool
|
||||
subset and a slot-specific system prompt. Every fact the model states
|
||||
is either derived from a tool result visible in the returned message
|
||||
list or it's the model hallucinating — so follow-up chat in the same
|
||||
conversation can hold the model to what the tool results actually show.
|
||||
|
||||
Returns ``(final_prose, message_list)`` where ``message_list`` is the
|
||||
full sequence including system, user trigger, tool calls, and tool
|
||||
results. Callers are expected to persist those intermediate turns
|
||||
alongside the final prose so the receipts remain in conversation
|
||||
history on follow-up.
|
||||
|
||||
If the loop fails or the model returns empty prose, returns
|
||||
``("", [])`` and the caller should fall back to the legacy path.
|
||||
"""
|
||||
from fabledassistant.services.llm import stream_chat_with_tools, ChatChunk # noqa: F401
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
from fabledassistant.services.briefing_tools import get_briefing_tools
|
||||
from fabledassistant.services.user_profile import build_profile_context
|
||||
|
||||
profile_context = await build_profile_context(user_id)
|
||||
tools = await get_briefing_tools(user_id)
|
||||
|
||||
if not tools:
|
||||
logger.warning(
|
||||
"Agentic briefing for user %d slot %s: no tools available — aborting",
|
||||
user_id, slot,
|
||||
)
|
||||
return "", []
|
||||
|
||||
# Compute today's window in the user's local timezone so the model
|
||||
# receives ready-to-use ISO 8601 boundaries and never has to do its
|
||||
# own tz math when calling date-sensitive tools like list_events.
|
||||
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
tz_name = "UTC"
|
||||
now_local = datetime.now(user_tz)
|
||||
today_iso = now_local.date().isoformat()
|
||||
day_start = datetime(now_local.year, now_local.month, now_local.day, 0, 0, 0, tzinfo=user_tz)
|
||||
day_end = datetime(now_local.year, now_local.month, now_local.day, 23, 59, 59, tzinfo=user_tz)
|
||||
day_from_iso = day_start.isoformat()
|
||||
day_to_iso = day_end.isoformat()
|
||||
|
||||
system_prompt = _agentic_system_prompt(
|
||||
profile_context, slot, today_iso, tz_name, day_from_iso, day_to_iso,
|
||||
)
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": _agentic_user_trigger(slot, today_iso)},
|
||||
]
|
||||
|
||||
final_text = ""
|
||||
for round_idx in range(_BRIEFING_AGENT_MAX_ROUNDS):
|
||||
accumulated_content = ""
|
||||
accumulated_tool_calls: list[dict] = []
|
||||
|
||||
try:
|
||||
async for chunk in stream_chat_with_tools(
|
||||
messages, model, tools=tools, think=False,
|
||||
num_ctx=_BRIEFING_AGENT_NUM_CTX,
|
||||
):
|
||||
if chunk.type == "content" and chunk.content:
|
||||
accumulated_content += chunk.content
|
||||
elif chunk.type == "tool_calls" and chunk.tool_calls:
|
||||
accumulated_tool_calls.extend(chunk.tool_calls)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Agentic briefing stream failed (user %d, slot %s, round %d)",
|
||||
user_id, slot, round_idx, exc_info=True,
|
||||
)
|
||||
return "", []
|
||||
|
||||
# Append the assistant turn (content + any tool calls) to history
|
||||
assistant_msg: dict = {"role": "assistant", "content": accumulated_content}
|
||||
if accumulated_tool_calls:
|
||||
assistant_msg["tool_calls"] = accumulated_tool_calls
|
||||
messages.append(assistant_msg)
|
||||
|
||||
# No tool calls → the model is done
|
||||
if not accumulated_tool_calls:
|
||||
final_text = accumulated_content.strip()
|
||||
break
|
||||
|
||||
# Execute each tool call and append results as tool-role messages
|
||||
for tc in accumulated_tool_calls:
|
||||
fn = tc.get("function") or {}
|
||||
tool_name = fn.get("name", "")
|
||||
arguments = fn.get("arguments") or {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
import json as _json
|
||||
arguments = _json.loads(arguments)
|
||||
except Exception:
|
||||
arguments = {}
|
||||
|
||||
# Default list_tasks to active statuses only so cancelled/done
|
||||
# items don't slip into briefing prose. The model can still
|
||||
# pass an explicit status filter when it wants something else.
|
||||
if tool_name == "list_tasks" and not arguments.get("status"):
|
||||
arguments["status"] = ["todo", "in_progress"]
|
||||
|
||||
try:
|
||||
if tool_name == "get_rss_items" and rss_override is not None:
|
||||
# Use topic-scored/filtered items already computed by
|
||||
# the briefing pipeline rather than the raw feed dump
|
||||
# that execute_tool would return. Keeps the model's
|
||||
# view of news aligned with the user's topic prefs
|
||||
# and the sidebar's rss_item_ids metadata.
|
||||
slim = [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"source": item.get("feed_title", ""),
|
||||
"summary": (item.get("content") or "")[:400],
|
||||
"published_at": item.get("published_at"),
|
||||
"topics": item.get("topics") or [],
|
||||
}
|
||||
for item in rss_override
|
||||
]
|
||||
result = {"success": True, "data": {"items": slim, "count": len(slim)}}
|
||||
else:
|
||||
result = await execute_tool(user_id, tool_name, arguments, conv_id=conv_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Tool %s failed during agentic briefing: %s", tool_name, exc,
|
||||
)
|
||||
result = {"success": False, "error": str(exc)}
|
||||
|
||||
# Serialize the result compactly for the model's context
|
||||
import json as _json
|
||||
try:
|
||||
result_str = _json.dumps(result, default=str)[:4000]
|
||||
except Exception:
|
||||
result_str = str(result)[:4000]
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"content": result_str,
|
||||
"tool_name": tool_name,
|
||||
})
|
||||
else:
|
||||
logger.warning(
|
||||
"Agentic briefing hit max rounds (%d) for user %d slot %s — using last content",
|
||||
_BRIEFING_AGENT_MAX_ROUNDS, user_id, slot,
|
||||
)
|
||||
# Walk back to find the last assistant message with non-empty content
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "assistant" and m.get("content"):
|
||||
final_text = m["content"].strip()
|
||||
break
|
||||
|
||||
return final_text, messages
|
||||
|
||||
|
||||
# ── Main entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_temp_unit(user_id: int) -> str:
|
||||
"""Read the user's preferred temperature unit from briefing_config ('C' or 'F')."""
|
||||
import json
|
||||
raw = await get_setting(user_id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
unit = config.get("temp_unit", "C")
|
||||
return unit if unit in ("C", "F") else "C"
|
||||
except Exception:
|
||||
return "C"
|
||||
|
||||
|
||||
async def run_compilation(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Run the agentic briefing loop and gather UI metadata (RSS + weather).
|
||||
|
||||
Returns ``(briefing_text, metadata)`` where metadata contains
|
||||
``rss_item_ids``, ``rss_items``, ``weather`` for frontend rendering,
|
||||
and ``agentic_messages`` (the full tool-call sequence) for the
|
||||
scheduler to persist as separate conversation rows.
|
||||
"""
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
from fabledassistant.services.briefing_preferences import (
|
||||
load_topic_preferences,
|
||||
load_topic_reaction_scores,
|
||||
score_and_filter_items,
|
||||
)
|
||||
from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows
|
||||
|
||||
include_topics, exclude_topics = await load_topic_preferences(user_id)
|
||||
topic_scores = await load_topic_reaction_scores(user_id)
|
||||
|
||||
external_data, weather_rows, temp_unit = await asyncio.gather(
|
||||
_gather_external(user_id),
|
||||
get_cached_weather_rows(user_id),
|
||||
_get_temp_unit(user_id),
|
||||
)
|
||||
|
||||
raw_rss = external_data.get("rss_items") or []
|
||||
filtered_rss = score_and_filter_items(
|
||||
raw_rss,
|
||||
include_topics=include_topics,
|
||||
exclude_topics=exclude_topics,
|
||||
topic_scores=topic_scores,
|
||||
max_items=10,
|
||||
)
|
||||
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
|
||||
rss_items_meta = [
|
||||
{
|
||||
"id": item["id"],
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"source": item.get("feed_title", ""),
|
||||
"snippet": (item.get("content") or "")[:300],
|
||||
"published_at": item.get("published_at"),
|
||||
}
|
||||
for item in filtered_rss
|
||||
if item.get("id")
|
||||
]
|
||||
|
||||
weather_cards = [
|
||||
card for row in weather_rows
|
||||
if (card := parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
weather_card = weather_cards[0] if weather_cards else None
|
||||
|
||||
briefing_text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None, rss_override=filtered_rss,
|
||||
)
|
||||
|
||||
metadata: dict = {
|
||||
"rss_item_ids": rss_item_ids,
|
||||
"rss_items": rss_items_meta,
|
||||
"weather": weather_card,
|
||||
}
|
||||
if agentic_messages:
|
||||
metadata["agentic_messages"] = agentic_messages
|
||||
|
||||
if not briefing_text:
|
||||
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
|
||||
return "", metadata
|
||||
|
||||
return briefing_text, metadata
|
||||
|
||||
|
||||
async def run_slot_injection(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Lighter check-in update for 8am/12pm/4pm slots.
|
||||
|
||||
Runs the agentic loop with the slot-specific prompt. Returns
|
||||
``(text, metadata)`` where metadata contains ``agentic_messages``
|
||||
for the scheduler to persist.
|
||||
"""
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None,
|
||||
)
|
||||
|
||||
metadata: dict = {}
|
||||
if agentic_messages:
|
||||
metadata["agentic_messages"] = agentic_messages
|
||||
return text, metadata
|
||||
@@ -1,110 +0,0 @@
|
||||
"""
|
||||
Briefing preferences: load topic settings, aggregate reaction scores,
|
||||
filter and rank RSS items for briefing inclusion.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def load_topic_preferences(user_id: int) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Return (include_topics, exclude_topics) from user settings.
|
||||
"""
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
raw_include = await get_setting(user_id, "briefing_include_topics", "[]")
|
||||
raw_exclude = await get_setting(user_id, "briefing_exclude_topics", "[]")
|
||||
|
||||
def _parse(raw) -> list[str]:
|
||||
try:
|
||||
val = json.loads(raw) if isinstance(raw, str) else raw
|
||||
return [str(t) for t in val] if isinstance(val, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
return _parse(raw_include), _parse(raw_exclude)
|
||||
|
||||
|
||||
async def load_topic_reaction_scores(user_id: int) -> dict[str, float]:
|
||||
"""
|
||||
Aggregate per-topic reaction scores from the last 30 days.
|
||||
Returns a dict of topic -> net_score (positive = liked, negative = disliked).
|
||||
Uses rss_item_reactions joined to rss_items.topics.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT unnest(i.topics) AS topic,
|
||||
SUM(CASE r.reaction WHEN 'up' THEN 1 ELSE -1 END) AS score
|
||||
FROM rss_item_reactions r
|
||||
JOIN rss_items i ON i.id = r.rss_item_id
|
||||
WHERE r.user_id = :uid
|
||||
AND r.created_at > NOW() - INTERVAL '30 days'
|
||||
GROUP BY topic
|
||||
""").bindparams(uid=user_id)
|
||||
)
|
||||
return {row.topic: float(row.score) for row in result}
|
||||
except Exception:
|
||||
logger.warning("Failed to load topic reaction scores", exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def score_and_filter_items(
|
||||
items: list[dict],
|
||||
include_topics: list[str],
|
||||
exclude_topics: list[str],
|
||||
topic_scores: dict[str, float],
|
||||
max_items: int = 10,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Score, filter, and rank RSS items for briefing inclusion.
|
||||
|
||||
Scoring:
|
||||
- Hard-exclude: any item tagged with an excluded topic is removed.
|
||||
- Base score: 0.0
|
||||
- +2.0 per topic that appears in include_topics
|
||||
- +1.0 / -1.0 per topic based on reaction score (clamped per topic)
|
||||
- Tiebreak: newer published_at wins
|
||||
|
||||
Returns up to max_items items, highest score first.
|
||||
Items with classified_at=None (unclassified) pass through with score=0.
|
||||
"""
|
||||
include_set = set(include_topics)
|
||||
exclude_set = set(exclude_topics)
|
||||
scored = []
|
||||
|
||||
for item in items:
|
||||
item_topics = item.get("topics") or []
|
||||
|
||||
# Hard exclude
|
||||
if exclude_set and any(t in exclude_set for t in item_topics):
|
||||
continue
|
||||
|
||||
score = 0.0
|
||||
for topic in item_topics:
|
||||
if topic in include_set:
|
||||
score += 2.0
|
||||
if topic in topic_scores:
|
||||
score += max(-1.0, min(1.0, topic_scores[topic]))
|
||||
|
||||
# Parse published_at for tiebreak
|
||||
pub_str = item.get("published_at") or ""
|
||||
try:
|
||||
pub_ts = datetime.fromisoformat(pub_str).timestamp() if pub_str else 0.0
|
||||
except ValueError:
|
||||
pub_ts = 0.0
|
||||
|
||||
scored.append((score, pub_ts, item))
|
||||
|
||||
# Sort: highest score first, then newest first
|
||||
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
return [item for _, _, item in scored[:max_items]]
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Briefing profile note: stores learned user preferences for the briefing assistant."""
|
||||
|
||||
import logging
|
||||
|
||||
from fabledassistant.services.notes import create_note, list_notes, update_note
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROFILE_TAG = "briefing-profile"
|
||||
PROFILE_TITLE = "Briefing Profile"
|
||||
|
||||
|
||||
async def _find_profile_note(user_id: int) -> dict | None:
|
||||
"""Find the user's briefing profile note by tag."""
|
||||
notes, _total = await list_notes(user_id, tags=[PROFILE_TAG], limit=1)
|
||||
if not notes:
|
||||
return None
|
||||
note = notes[0]
|
||||
return {
|
||||
"id": note.id,
|
||||
"body": note.body or "",
|
||||
"title": note.title,
|
||||
}
|
||||
|
||||
|
||||
async def get_profile_body(user_id: int) -> str:
|
||||
"""Return the body of the briefing profile note, or '' if none exists."""
|
||||
note = await _find_profile_note(user_id)
|
||||
return note["body"] if note else ""
|
||||
|
||||
|
||||
async def get_profile_note_id(user_id: int) -> int | None:
|
||||
note = await _find_profile_note(user_id)
|
||||
return note["id"] if note else None
|
||||
|
||||
|
||||
async def ensure_profile_note(user_id: int) -> int:
|
||||
"""
|
||||
Get or create the briefing profile note.
|
||||
Returns the note id.
|
||||
"""
|
||||
note = await _find_profile_note(user_id)
|
||||
if note:
|
||||
return note["id"]
|
||||
created = await create_note(
|
||||
user_id=user_id,
|
||||
title=PROFILE_TITLE,
|
||||
body=(
|
||||
"# Briefing Profile\n\n"
|
||||
"This note is maintained by the briefing assistant. "
|
||||
"It stores your preferences, patterns, and work schedule.\n\n"
|
||||
"## Work Schedule\n\n"
|
||||
"Office days: (not yet configured)\n\n"
|
||||
"## Locations\n\n"
|
||||
"(configured via Settings → Briefing)\n\n"
|
||||
"## Preferences\n\n"
|
||||
"(the assistant will add observations here over time)\n"
|
||||
),
|
||||
tags=[PROFILE_TAG],
|
||||
)
|
||||
return created.id
|
||||
|
||||
|
||||
async def append_observations(user_id: int, observations: str) -> None:
|
||||
"""
|
||||
Append the assistant's end-of-day observations to the profile note.
|
||||
Creates the note if it doesn't exist.
|
||||
"""
|
||||
if not observations.strip():
|
||||
return
|
||||
note_id = await ensure_profile_note(user_id)
|
||||
note = await _find_profile_note(user_id)
|
||||
if not note:
|
||||
return
|
||||
current_body = note.get("body", "")
|
||||
from datetime import date
|
||||
date_str = date.today().isoformat()
|
||||
new_body = current_body.rstrip() + f"\n\n## Observations — {date_str}\n\n{observations.strip()}\n"
|
||||
await update_note(user_id, note_id, body=new_body)
|
||||
logger.info("Briefing profile updated for user %d", user_id)
|
||||
@@ -1,625 +0,0 @@
|
||||
"""
|
||||
APScheduler-based briefing scheduler — per-user, timezone-aware.
|
||||
|
||||
Each enabled user gets 4 individual CronTrigger jobs keyed to their IANA
|
||||
timezone (stored in briefing_config.timezone). Changing the config via the
|
||||
settings UI calls update_user_schedule() which live-patches the scheduler
|
||||
without a restart.
|
||||
|
||||
Uses a background thread scheduler (not async) because APScheduler 3.x's
|
||||
AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async
|
||||
functions wrapped with asyncio.run_coroutine_threadsafe().
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# Slot definitions: (name, hour, minute) — local time in the user's timezone
|
||||
SLOTS = [
|
||||
("compilation", 4, 0),
|
||||
("morning", 8, 0),
|
||||
("midday", 12, 0),
|
||||
("afternoon", 16, 0),
|
||||
]
|
||||
|
||||
# Weekly review runs Sunday at 6pm by default
|
||||
WEEKLY_REVIEW_DAY = "sun" # APScheduler day_of_week format
|
||||
WEEKLY_REVIEW_HOUR = 18
|
||||
WEEKLY_REVIEW_MINUTE = 0
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_timezone(tz_str: str) -> str:
|
||||
"""Validate and return an IANA timezone string, falling back to UTC."""
|
||||
if not tz_str:
|
||||
return "UTC"
|
||||
try:
|
||||
ZoneInfo(tz_str)
|
||||
return tz_str
|
||||
except (ZoneInfoNotFoundError, KeyError):
|
||||
logger.warning("Invalid timezone %r in briefing config, falling back to UTC", tz_str)
|
||||
return "UTC"
|
||||
|
||||
|
||||
async def _get_briefing_enabled_users() -> list[tuple[int, str, dict]]:
|
||||
"""Return [(user_id, iana_timezone, config)] for all users with briefing enabled."""
|
||||
import json
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.key.in_(["briefing_config", "user_timezone"]))
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
by_user: dict[int, dict[str, str]] = {}
|
||||
for row in rows:
|
||||
by_user.setdefault(row.user_id, {})[row.key] = row.value or ""
|
||||
|
||||
enabled = []
|
||||
for user_id, settings in by_user.items():
|
||||
try:
|
||||
config = json.loads(settings.get("briefing_config", "{}") or "{}")
|
||||
if config.get("enabled"):
|
||||
tz_str = settings.get("user_timezone") or config.get("timezone", "UTC")
|
||||
tz = _resolve_timezone(tz_str)
|
||||
enabled.append((user_id, tz, config))
|
||||
except Exception:
|
||||
pass
|
||||
return enabled
|
||||
|
||||
|
||||
def _job_id(user_id: int, slot: str) -> str:
|
||||
return f"briefing_{slot}_user_{user_id}"
|
||||
|
||||
|
||||
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
||||
"""Add (or replace) slot jobs for a user, skipping disabled slots."""
|
||||
if _scheduler is None or _loop is None:
|
||||
return
|
||||
enabled_slots = (config or {}).get("slots", {})
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
jid = _job_id(user_id, slot_name)
|
||||
# compilation always runs; other slots default to True if not in config
|
||||
slot_on = enabled_slots.get(slot_name, True)
|
||||
if not slot_on:
|
||||
if _scheduler.get_job(jid):
|
||||
_scheduler.remove_job(jid)
|
||||
continue
|
||||
_scheduler.add_job(
|
||||
_run_user_slot_sync,
|
||||
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
||||
args=[user_id, slot_name],
|
||||
id=jid,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
# Weekly review job — runs once per week
|
||||
weekly_jid = _job_id(user_id, "weekly_review")
|
||||
weekly_on = enabled_slots.get("weekly_review", True)
|
||||
if weekly_on:
|
||||
_scheduler.add_job(
|
||||
_run_user_slot_sync,
|
||||
CronTrigger(
|
||||
day_of_week=WEEKLY_REVIEW_DAY,
|
||||
hour=WEEKLY_REVIEW_HOUR,
|
||||
minute=WEEKLY_REVIEW_MINUTE,
|
||||
timezone=tz,
|
||||
),
|
||||
args=[user_id, "weekly_review"],
|
||||
id=weekly_jid,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=7200,
|
||||
)
|
||||
elif _scheduler.get_job(weekly_jid):
|
||||
_scheduler.remove_job(weekly_jid)
|
||||
|
||||
logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz)
|
||||
|
||||
|
||||
def _remove_user_jobs(user_id: int) -> None:
|
||||
"""Remove all slot jobs for a user."""
|
||||
if _scheduler is None:
|
||||
return
|
||||
for slot_name, _, _ in SLOTS:
|
||||
jid = _job_id(user_id, slot_name)
|
||||
if _scheduler.get_job(jid):
|
||||
_scheduler.remove_job(jid)
|
||||
weekly_jid = _job_id(user_id, "weekly_review")
|
||||
if _scheduler.get_job(weekly_jid):
|
||||
_scheduler.remove_job(weekly_jid)
|
||||
logger.info("Removed briefing jobs for user %d", user_id)
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def update_user_schedule(user_id: int, config: dict, tz_override: str | None = None) -> None:
|
||||
"""
|
||||
Called when a user saves their briefing config via the settings UI.
|
||||
Live-patches the scheduler — no restart required.
|
||||
tz_override takes priority over any timezone in config.
|
||||
"""
|
||||
if config.get("enabled"):
|
||||
tz_str = tz_override or config.get("timezone", "UTC")
|
||||
tz = _resolve_timezone(tz_str)
|
||||
_add_user_jobs(user_id, tz, config)
|
||||
else:
|
||||
_remove_user_jobs(user_id)
|
||||
|
||||
|
||||
# ── Job execution ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _auto_pause_stale_projects(user_id: int) -> list[str]:
|
||||
"""Pause active projects with no note/task activity in 14+ days. Returns paused project titles."""
|
||||
from sqlalchemy import select as _sel, func as _func
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models import async_session as _session
|
||||
|
||||
paused: list[str] = []
|
||||
threshold = datetime.now(timezone.utc) - timedelta(days=14)
|
||||
try:
|
||||
async with _session() as session:
|
||||
projects = (await session.execute(
|
||||
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
|
||||
)).scalars().all()
|
||||
for p in projects:
|
||||
latest = (await session.execute(
|
||||
_sel(_func.max(Note.updated_at)).where(Note.project_id == p.id)
|
||||
)).scalar()
|
||||
if latest is None or latest < threshold:
|
||||
p.status = "paused"
|
||||
paused.append(p.title or "Untitled")
|
||||
if paused:
|
||||
await session.commit()
|
||||
logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused)
|
||||
except Exception:
|
||||
logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True)
|
||||
return paused
|
||||
|
||||
|
||||
async def _persist_agentic_messages(
|
||||
conv_id: int,
|
||||
slot: str,
|
||||
metadata: dict | None,
|
||||
) -> None:
|
||||
"""Persist the intermediate turns from an agentic briefing run.
|
||||
|
||||
``metadata["agentic_messages"]`` is the full message list the agent
|
||||
generated — system prompt, user trigger, assistant tool-call turns,
|
||||
tool-role results, and the final assistant prose.
|
||||
|
||||
To stay compatible with the existing chat loader in ``routes/chat.py``,
|
||||
tool results are folded back into the parent assistant message's
|
||||
``tool_calls[i]["result"]`` field rather than being persisted as
|
||||
separate ``role="tool"`` rows. This matches how regular chat
|
||||
persists agentic turns, so the follow-up chat endpoint can rehydrate
|
||||
the tool sequence using its existing logic.
|
||||
|
||||
Persists everything except the system prompt (implicit in the chat
|
||||
pipeline) and the final assistant prose (the caller posts that
|
||||
separately with the user-facing metadata block). The synthetic user
|
||||
trigger message is persisted so Ollama sees a user→assistant→user
|
||||
sequence rather than an orphaned assistant reply — it's tagged as
|
||||
intermediate so the UI can hide it.
|
||||
|
||||
Legacy (non-agentic) briefings have no ``agentic_messages`` and this
|
||||
function is a no-op.
|
||||
"""
|
||||
from fabledassistant.services.briefing_conversations import post_message
|
||||
|
||||
if not metadata:
|
||||
return
|
||||
agentic_messages = metadata.get("agentic_messages") or []
|
||||
if not agentic_messages:
|
||||
return
|
||||
|
||||
# Drop the system prompt (index 0) and the final assistant prose
|
||||
# (last item). The caller posts the final prose as its own message.
|
||||
middle = agentic_messages[1:-1]
|
||||
|
||||
# Walk the middle sequence, pairing each assistant tool-call turn
|
||||
# with the tool-role results that immediately follow it.
|
||||
i = 0
|
||||
n = len(middle)
|
||||
while i < n:
|
||||
m = middle[i]
|
||||
role = m.get("role", "")
|
||||
content = m.get("content", "") or ""
|
||||
tag = {"briefing_slot": slot, "briefing_intermediate": True}
|
||||
|
||||
if role == "assistant" and m.get("tool_calls"):
|
||||
# Collect subsequent tool-role results, matching them
|
||||
# positionally onto this assistant's tool_calls. Normalise
|
||||
# each entry to the flat storage format the chat loader
|
||||
# expects: {"function": <name>, "arguments": <args>,
|
||||
# "result": <result>, "status": "success"|"error"}.
|
||||
raw_tool_calls = list(m["tool_calls"])
|
||||
flat_tool_calls: list[dict] = []
|
||||
result_idx = 0
|
||||
j = i + 1
|
||||
|
||||
import json as _json
|
||||
for raw_tc in raw_tool_calls:
|
||||
fn = raw_tc.get("function") or {}
|
||||
name = fn.get("name") if isinstance(fn, dict) else str(fn)
|
||||
arguments = fn.get("arguments") if isinstance(fn, dict) else {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = _json.loads(arguments)
|
||||
except Exception:
|
||||
arguments = {}
|
||||
|
||||
# Pair up with the next available tool-role message
|
||||
parsed_result: object = {}
|
||||
status = "success"
|
||||
if j < n and middle[j].get("role") == "tool":
|
||||
tool_content = middle[j].get("content", "") or ""
|
||||
try:
|
||||
parsed_result = _json.loads(tool_content)
|
||||
except Exception:
|
||||
parsed_result = tool_content
|
||||
if isinstance(parsed_result, dict) and parsed_result.get("success") is False:
|
||||
status = "error"
|
||||
j += 1
|
||||
result_idx += 1
|
||||
|
||||
flat_tool_calls.append({
|
||||
"function": name,
|
||||
"arguments": arguments,
|
||||
"result": parsed_result,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
try:
|
||||
await post_message(
|
||||
conv_id, "assistant", content,
|
||||
metadata=tag,
|
||||
tool_calls=flat_tool_calls,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist agentic assistant turn for conv %d slot %s",
|
||||
conv_id, slot, exc_info=True,
|
||||
)
|
||||
i = j # skip the tool results we just folded in
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
# Unpaired tool result — shouldn't normally happen, but be
|
||||
# defensive and persist it as an assistant-visible note so we
|
||||
# don't lose the receipt entirely.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
# Skip the synthetic user trigger ("Generate my morning briefing…").
|
||||
# Persisting it would recreate the exact "[Midday briefing update]"
|
||||
# problem PR 2 is designed to eliminate: fake user messages
|
||||
# cluttering chat history. The LLM can follow an all-assistant
|
||||
# sequence just fine since the chat endpoint injects the real
|
||||
# system prompt on follow-up.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# assistant without tool_calls — persist as-is (rare intermediate)
|
||||
try:
|
||||
await post_message(conv_id, role, content, metadata=tag)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist agentic %s message for conv %d slot %s",
|
||||
role, conv_id, slot, exc_info=True,
|
||||
)
|
||||
i += 1
|
||||
|
||||
|
||||
async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
||||
"""Execute one slot job for one user."""
|
||||
from fabledassistant.services.briefing_conversations import (
|
||||
get_or_create_today_conversation, post_message
|
||||
)
|
||||
from fabledassistant.services.briefing_pipeline import run_compilation, run_slot_injection
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.config import Config
|
||||
|
||||
# Morning slot: skip if today is not a configured work day
|
||||
if slot == "morning":
|
||||
from fabledassistant.services.user_profile import get_profile
|
||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
|
||||
profile = await get_profile(user_id)
|
||||
work_days = (profile.work_schedule or {}).get("days", ["Mon", "Tue", "Wed", "Thu", "Fri"])
|
||||
if today_abbr not in work_days:
|
||||
logger.info(
|
||||
"Skipping morning slot for user %d — %s not a configured work day",
|
||||
user_id, today_abbr,
|
||||
)
|
||||
return
|
||||
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
if slot == "compilation":
|
||||
# Auto-pause stale projects before compiling the briefing
|
||||
await _auto_pause_stale_projects(user_id)
|
||||
|
||||
# Refresh external data first
|
||||
try:
|
||||
import json
|
||||
config_raw = await get_setting(user_id, "briefing_config", "{}")
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
if rss_on:
|
||||
from fabledassistant.services.rss import refresh_all_feeds
|
||||
await refresh_all_feeds(user_id)
|
||||
from fabledassistant.services import weather as wx
|
||||
for key, loc in config.get("locations", {}).items():
|
||||
if loc.get("lat") and loc.get("lon"):
|
||||
await wx.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True)
|
||||
|
||||
await _run_profile_closeout(user_id, model)
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text, metadata = await run_compilation(user_id, slot, model)
|
||||
if text:
|
||||
# Persist the agentic tool-call sequence as its own messages
|
||||
# so follow-up chat can see the receipts. Each intermediate
|
||||
# message is tagged with briefing_slot so the chat context
|
||||
# loader can decide whether to include them in history.
|
||||
await _persist_agentic_messages(conv.id, slot, metadata)
|
||||
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
|
||||
else:
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text, slot_metadata = await run_slot_injection(user_id, slot, model)
|
||||
if text:
|
||||
# No more synthetic "[Midday briefing update]" user-role
|
||||
# messages. Slot updates are plain assistant messages tagged
|
||||
# with briefing_slot so the chat endpoint can filter them
|
||||
# from the LLM's view of history on follow-ups (they remain
|
||||
# visible in the UI).
|
||||
await _persist_agentic_messages(conv.id, slot, slot_metadata)
|
||||
final_meta = {k: v for k, v in slot_metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification
|
||||
slot_labels = {
|
||||
"compilation": "Morning briefing ready",
|
||||
"morning": "Office briefing ready",
|
||||
"midday": "Midday check-in",
|
||||
"afternoon": "End of day wrap-up",
|
||||
}
|
||||
await send_push_notification(
|
||||
user_id=user_id,
|
||||
title="Briefing",
|
||||
body=slot_labels.get(slot, "Briefing update"),
|
||||
url="/briefing",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Push notification failed for briefing slot %s", slot, exc_info=True)
|
||||
|
||||
logger.info("Briefing slot '%s' completed for user %d", slot, user_id)
|
||||
|
||||
|
||||
def _run_user_slot_sync(user_id: int, slot: str) -> None:
|
||||
"""Synchronous wrapper called by APScheduler's background thread."""
|
||||
if _loop is None:
|
||||
logger.error("No event loop available for briefing slot %s user %d", slot, user_id)
|
||||
return
|
||||
future = asyncio.run_coroutine_threadsafe(_run_slot_for_user(user_id, slot), _loop)
|
||||
try:
|
||||
future.result(timeout=600)
|
||||
except Exception:
|
||||
logger.exception("Briefing slot '%s' failed for user %d", slot, user_id)
|
||||
|
||||
|
||||
async def _run_profile_closeout(user_id: int, model: str) -> None:
|
||||
"""
|
||||
Read yesterday's briefing conversation, extract preference observations,
|
||||
and append them to the briefing profile note.
|
||||
"""
|
||||
from fabledassistant.services.user_profile import append_observations
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.tz import user_today
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
|
||||
# User-local "yesterday" so closeout always targets the day that just
|
||||
# ended in the user's timezone, regardless of container TZ.
|
||||
yesterday = (await user_today(user_id)) - timedelta(days=1)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == yesterday,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if not conv:
|
||||
return
|
||||
msgs_result = await session.execute(
|
||||
select(Message).where(Message.conversation_id == conv.id).order_by(Message.created_at)
|
||||
)
|
||||
messages = list(msgs_result.scalars().all())
|
||||
|
||||
if len(messages) < 2:
|
||||
return
|
||||
|
||||
transcript = "\n".join(
|
||||
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:]
|
||||
)
|
||||
system = (
|
||||
"You are reviewing a day's briefing conversation to extract preference observations. "
|
||||
"Identify any patterns, preferences, or schedule facts the user revealed. "
|
||||
"Write 2-5 short bullet points. Be specific and factual. "
|
||||
"Example: '- User skipped news about finance', '- Prefers weather for home location first'. "
|
||||
"If nothing notable, output only: (nothing to note)"
|
||||
)
|
||||
try:
|
||||
observations = (await generate_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": transcript},
|
||||
],
|
||||
model,
|
||||
)).strip()
|
||||
except Exception:
|
||||
logger.warning("Profile closeout synthesis failed for user %d", user_id, exc_info=True)
|
||||
observations = ""
|
||||
if observations and "(nothing to note)" not in observations.lower():
|
||||
await append_observations(user_id, observations)
|
||||
|
||||
|
||||
# ── Startup / catchup ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""
|
||||
On startup, fire any slot that was missed in the last 24 hours
|
||||
(one catch-up per slot per user, evaluated in the user's local timezone).
|
||||
"""
|
||||
users = await _get_briefing_enabled_users()
|
||||
for user_id, tz, _config in users:
|
||||
user_tz = ZoneInfo(tz)
|
||||
now_local = datetime.now(user_tz)
|
||||
today_local = now_local.date()
|
||||
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
slot_local = datetime.combine(today_local, time(hour, minute), tzinfo=user_tz)
|
||||
if slot_local > now_local:
|
||||
continue # Not yet due
|
||||
age = (now_local - slot_local).total_seconds()
|
||||
if age > 86400:
|
||||
continue # More than 24h ago — skip
|
||||
|
||||
# Check if today's conversation already has a message from after slot time
|
||||
async with async_session() as session:
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today_local,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv:
|
||||
# Convert slot_local to UTC for DB comparison (stored as UTC)
|
||||
slot_utc = slot_local.astimezone(ZoneInfo("UTC"))
|
||||
msgs = await session.execute(
|
||||
select(Message).where(
|
||||
Message.conversation_id == conv.id,
|
||||
Message.created_at >= slot_utc,
|
||||
).limit(1)
|
||||
)
|
||||
if msgs.scalars().first():
|
||||
continue # Already covered
|
||||
|
||||
logger.info(
|
||||
"Catching up missed briefing slot '%s' for user %d (tz: %s)",
|
||||
slot_name, user_id, tz,
|
||||
)
|
||||
try:
|
||||
await _run_slot_for_user(user_id, slot_name)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Catch-up for slot '%s' user %d failed", slot_name, user_id
|
||||
)
|
||||
|
||||
|
||||
async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""
|
||||
Start the APScheduler background scheduler with per-user timezone-aware jobs.
|
||||
Must be awaited from the app's before_serving hook (async context).
|
||||
"""
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler(timezone="UTC")
|
||||
|
||||
# Await directly — we're already on the event loop, so run_coroutine_threadsafe
|
||||
# would deadlock (it blocks the calling thread, which IS the event loop thread).
|
||||
try:
|
||||
users = await _get_briefing_enabled_users()
|
||||
except Exception:
|
||||
logger.exception("Failed to load briefing users at startup")
|
||||
users = []
|
||||
|
||||
for user_id, tz, config in users:
|
||||
_add_user_jobs(user_id, tz, config)
|
||||
|
||||
from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring
|
||||
|
||||
def _run_recurrence_spawn() -> None:
|
||||
future = asyncio.run_coroutine_threadsafe(_spawn_recurring(), _loop)
|
||||
try:
|
||||
count = future.result(timeout=300)
|
||||
logger.info("Recurrence spawn: %d task(s) created", count)
|
||||
except Exception as exc:
|
||||
logger.error("Recurrence spawn failed: %s", exc)
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_recurrence_spawn,
|
||||
CronTrigger(hour=0, minute=0, timezone="UTC"),
|
||||
id="recurrence_daily",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
def _run_kokoro_update_check() -> None:
|
||||
from fabledassistant.services.tts import check_for_kokoro_updates
|
||||
future = asyncio.run_coroutine_threadsafe(check_for_kokoro_updates(), _loop)
|
||||
try:
|
||||
future.result(timeout=300)
|
||||
except Exception as exc:
|
||||
logger.error("Kokoro update check failed: %s", exc)
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_kokoro_update_check,
|
||||
CronTrigger(hour=3, minute=0, timezone="UTC"),
|
||||
id="kokoro_update_check_daily",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info(
|
||||
"Briefing scheduler started with %d user(s) across %d job(s)",
|
||||
len(users), len(users) * len(SLOTS),
|
||||
)
|
||||
|
||||
asyncio.create_task(_catchup_missed_slots(loop))
|
||||
|
||||
|
||||
def stop_briefing_scheduler() -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
_loop = None
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Briefing tool subset — delegates to the registry's ``briefing=True`` filter.
|
||||
|
||||
Tools are opted-in to briefings via ``@tool(briefing=True)`` in their
|
||||
respective module, so there is no separate allowlist to maintain here.
|
||||
"""
|
||||
|
||||
from fabledassistant.services.tools import get_briefing_tools
|
||||
|
||||
__all__ = ["get_briefing_tools"]
|
||||
@@ -130,6 +130,17 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
async with async_session() as session:
|
||||
for ev in remote_events:
|
||||
caldav_uid = ev["caldav_uid"]
|
||||
# Storage uses duration, not end_dt. Convert here so the
|
||||
# rest of this function can compare/upsert in one shape.
|
||||
ev_start = ev["start_dt"]
|
||||
ev_end = ev["end_dt"]
|
||||
ev_duration = (
|
||||
int((ev_end - ev_start).total_seconds() // 60)
|
||||
if ev_end is not None and ev_start is not None and ev_end > ev_start
|
||||
else None
|
||||
)
|
||||
ev["duration_minutes"] = ev_duration
|
||||
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
@@ -145,8 +156,8 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
uid=str(uuid.uuid4()),
|
||||
caldav_uid=caldav_uid,
|
||||
title=ev["title"],
|
||||
start_dt=ev["start_dt"],
|
||||
end_dt=ev["end_dt"],
|
||||
start_dt=ev_start,
|
||||
duration_minutes=ev_duration,
|
||||
all_day=ev["all_day"],
|
||||
description=ev["description"],
|
||||
location=ev["location"],
|
||||
@@ -157,7 +168,7 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
else:
|
||||
# Update if anything changed
|
||||
changed = False
|
||||
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"):
|
||||
for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"):
|
||||
if getattr(existing, field) != ev[field]:
|
||||
setattr(existing, field, ev[field])
|
||||
changed = True
|
||||
|
||||
@@ -66,7 +66,7 @@ async def setup_user_calendar(user_id: int) -> bool:
|
||||
<set>
|
||||
<prop>
|
||||
<resourcetype><collection/><C:calendar/></resourcetype>
|
||||
<displayname>Fabled Assistant</displayname>
|
||||
<displayname>Fabled Scribe</displayname>
|
||||
</prop>
|
||||
</set>
|
||||
</mkcol>""",
|
||||
@@ -207,6 +207,14 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
d = dtend.dt
|
||||
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
|
||||
# Storage uses duration, not end_dt. Convert iCal DTEND to a
|
||||
# minute count anchored on DTSTART. Treat invalid (end <= start)
|
||||
# incoming data as a point event rather than rejecting; we
|
||||
# don't control external CalDAV writers.
|
||||
duration_minutes = None
|
||||
if end_dt is not None and end_dt > start_dt:
|
||||
duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
|
||||
@@ -215,7 +223,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.start_dt = start_dt
|
||||
existing.end_dt = end_dt
|
||||
existing.duration_minutes = duration_minutes
|
||||
existing.all_day = all_day
|
||||
existing.description = description
|
||||
existing.location = location
|
||||
@@ -230,7 +238,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
uid=ical_uid,
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=duration_minutes,
|
||||
all_day=all_day,
|
||||
description=description,
|
||||
location=location,
|
||||
|
||||
@@ -80,7 +80,7 @@ async def list_conversations(
|
||||
"title": conv.title,
|
||||
"model": conv.model,
|
||||
"conversation_type": conv.conversation_type,
|
||||
"briefing_date": conv.briefing_date.isoformat() if conv.briefing_date else None,
|
||||
"day_date": conv.day_date.isoformat() if conv.day_date else None,
|
||||
"rag_project_id": conv.rag_project_id,
|
||||
"message_count": row[1],
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
|
||||
@@ -38,7 +38,7 @@ _EMAIL_LOGO_SVG = (
|
||||
|
||||
|
||||
def _email_html(title: str, body: str) -> str:
|
||||
"""Wrap email body content in the standard Fabled Assistant template."""
|
||||
"""Wrap email body content in the standard Fabled Scribe template."""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -56,7 +56,7 @@ def _email_html(title: str, body: str) -> str:
|
||||
<!-- Header -->
|
||||
<div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
|
||||
<div style="margin-bottom:8px;">
|
||||
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Assistant</span>
|
||||
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Scribe</span>
|
||||
</div>
|
||||
<p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
|
||||
</div>
|
||||
@@ -68,7 +68,7 @@ def _email_html(title: str, body: str) -> str:
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
|
||||
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
|
||||
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Scribe instance.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
|
||||
username = config.get("smtp_username", "")
|
||||
password = config.get("smtp_password", "")
|
||||
from_address = config.get("smtp_from_address", "")
|
||||
from_name = config.get("smtp_from_name", "Fabled Assistant")
|
||||
from_name = config.get("smtp_from_name", "Fabled Scribe")
|
||||
use_tls = config.get("smtp_use_tls", "true") == "true"
|
||||
|
||||
msg = EmailMessage()
|
||||
@@ -176,6 +176,6 @@ async def send_test_email(to: str) -> None:
|
||||
"""Send a branded test email."""
|
||||
body = """
|
||||
<p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
|
||||
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
|
||||
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Scribe instance can send email notifications.</p>
|
||||
"""
|
||||
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
|
||||
await send_email(to, "Fabled Scribe - Test Email", _email_html("Test Email", body))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Semantic note search via Ollama embedding model (nomic-embed-text).
|
||||
|
||||
Embeddings are stored in the note_embeddings table (one row per note).
|
||||
RSS item embeddings are stored in rss_item_embeddings (one row per item).
|
||||
All search operations degrade gracefully — if the embedding model is
|
||||
unavailable the callers fall back to keyword search.
|
||||
"""
|
||||
@@ -9,7 +8,6 @@ unavailable the callers fall back to keyword search.
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import delete, select
|
||||
@@ -18,8 +16,6 @@ from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.embedding import NoteEmbedding
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
from fabledassistant.models.rss_item_embedding import RssItemEmbedding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,10 +24,6 @@ logger = logging.getLogger(__name__)
|
||||
# 0.45 keeps only genuinely relevant notes; lower values like 0.30 let in
|
||||
# loosely-related results that pad the sidebar without adding real value.
|
||||
_SIMILARITY_THRESHOLD = 0.45
|
||||
_RSS_SIMILARITY_THRESHOLD = 0.55
|
||||
_RSS_SEARCH_LIMIT = 3
|
||||
_RSS_SEARCH_DAYS = 30
|
||||
_RSS_SNIPPET_CHARS = 500
|
||||
|
||||
|
||||
async def get_embedding(text: str, model: str | None = None) -> list[float]:
|
||||
@@ -186,174 +178,3 @@ async def backfill_note_embeddings() -> None:
|
||||
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
|
||||
|
||||
|
||||
# ── RSS item embeddings ───────────────────────────────────────────────────────
|
||||
|
||||
async def upsert_rss_item_embedding(item_id: int, user_id: int, title: str, content: str) -> None:
|
||||
"""Generate and persist an embedding for an RSS item. Safe to fire-and-forget."""
|
||||
text = f"{title}\n{content}".strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
embedding = await get_embedding(text)
|
||||
except Exception:
|
||||
logger.debug("Skipping embedding for RSS item %d — model unavailable", item_id)
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
delete(RssItemEmbedding).where(RssItemEmbedding.rss_item_id == item_id)
|
||||
)
|
||||
session.add(RssItemEmbedding(rss_item_id=item_id, user_id=user_id, embedding=embedding))
|
||||
await session.commit()
|
||||
logger.debug("Upserted embedding for RSS item %d", item_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to persist embedding for RSS item %d", item_id, exc_info=True)
|
||||
|
||||
|
||||
async def semantic_search_rss_items(
|
||||
user_id: int,
|
||||
query_vector: list[float],
|
||||
limit: int = _RSS_SEARCH_LIMIT,
|
||||
days: int = _RSS_SEARCH_DAYS,
|
||||
) -> list[tuple[float, RssItem]]:
|
||||
"""Return up to *limit* (score, RssItem) pairs most relevant to *query_vector*.
|
||||
|
||||
Only considers items fetched within the last *days* days.
|
||||
Returns an empty list on any error.
|
||||
"""
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
try:
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(RssItemEmbedding, RssItem)
|
||||
.join(RssItem, RssItemEmbedding.rss_item_id == RssItem.id)
|
||||
.where(
|
||||
RssItemEmbedding.user_id == user_id,
|
||||
RssItem.fetched_at >= since,
|
||||
)
|
||||
)
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
except Exception:
|
||||
logger.warning("Failed to query RSS item embeddings", exc_info=True)
|
||||
return []
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
scored: list[tuple[float, RssItem]] = []
|
||||
for rie, item in rows:
|
||||
try:
|
||||
sim = _cosine_similarity(query_vector, rie.embedding)
|
||||
except Exception:
|
||||
continue
|
||||
if sim >= _RSS_SIMILARITY_THRESHOLD:
|
||||
scored.append((sim, item))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
|
||||
async def backfill_rss_item_embeddings() -> None:
|
||||
"""Generate embeddings for all RSS items that don't have one yet.
|
||||
|
||||
Runs as a background task at startup. Adds a small sleep between items
|
||||
to avoid overwhelming Ollama.
|
||||
"""
|
||||
try:
|
||||
async with async_session() as session:
|
||||
existing = {
|
||||
row[0]
|
||||
for row in (
|
||||
await session.execute(select(RssItemEmbedding.rss_item_id))
|
||||
).fetchall()
|
||||
}
|
||||
result = await session.execute(
|
||||
select(RssItem.id, RssItem.feed_id, RssItem.title, RssItem.content)
|
||||
)
|
||||
items_to_embed = [row for row in result.fetchall() if row[0] not in existing]
|
||||
except Exception:
|
||||
logger.warning("RSS embedding backfill: failed to query items", exc_info=True)
|
||||
return
|
||||
|
||||
if not items_to_embed:
|
||||
logger.info("RSS embedding backfill: all items already have embeddings")
|
||||
return
|
||||
|
||||
# Resolve user_id per feed_id
|
||||
try:
|
||||
from fabledassistant.models.rss_feed import RssFeed
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(RssFeed.id, RssFeed.user_id))
|
||||
feed_user_map = {fid: uid for fid, uid in result.fetchall()}
|
||||
except Exception:
|
||||
logger.warning("RSS embedding backfill: failed to load feed user map", exc_info=True)
|
||||
return
|
||||
|
||||
logger.info("RSS embedding backfill: generating embeddings for %d items", len(items_to_embed))
|
||||
success = 0
|
||||
for item_id, feed_id, title, content in items_to_embed:
|
||||
user_id = feed_user_map.get(feed_id)
|
||||
if user_id is None:
|
||||
continue
|
||||
await upsert_rss_item_embedding(item_id, user_id, title or "", content or "")
|
||||
success += 1
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
logger.info("RSS embedding backfill complete: %d/%d items embedded", success, len(items_to_embed))
|
||||
|
||||
|
||||
async def backfill_rss_article_content() -> None:
|
||||
"""Fetch full article text for RSS items that only have short feed-provided content.
|
||||
|
||||
An item is considered unenriched if its content is shorter than 1000 chars —
|
||||
typical of feed summaries/teasers rather than full articles.
|
||||
Runs at startup after the embedding backfill.
|
||||
"""
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
from fabledassistant.models.rss_feed import RssFeed
|
||||
|
||||
SHORT_THRESHOLD = 1000
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
feed_result = await session.execute(select(RssFeed.id, RssFeed.user_id))
|
||||
feed_user_map = {fid: uid for fid, uid in feed_result.fetchall()}
|
||||
|
||||
item_result = await session.execute(
|
||||
select(RssItem.id, RssItem.feed_id, RssItem.url, RssItem.title, RssItem.content)
|
||||
.where(RssItem.url != "")
|
||||
)
|
||||
candidates = [
|
||||
row for row in item_result.fetchall()
|
||||
if len(row[4] or "") < SHORT_THRESHOLD
|
||||
]
|
||||
except Exception:
|
||||
logger.warning("Article content backfill: failed to query items", exc_info=True)
|
||||
return
|
||||
|
||||
if not candidates:
|
||||
logger.info("Article content backfill: no unenriched items found")
|
||||
return
|
||||
|
||||
logger.info("Article content backfill: enriching %d items", len(candidates))
|
||||
enriched = 0
|
||||
for item_id, feed_id, url, title, _ in candidates:
|
||||
user_id = feed_user_map.get(feed_id)
|
||||
if user_id is None:
|
||||
continue
|
||||
full_text = await _fetch_full_article(url)
|
||||
if full_text and len(full_text) > SHORT_THRESHOLD:
|
||||
try:
|
||||
async with async_session() as session:
|
||||
item = await session.get(RssItem, item_id)
|
||||
if item:
|
||||
item.content = full_text
|
||||
await session.commit()
|
||||
await upsert_rss_item_embedding(item_id, user_id, title or "", full_text)
|
||||
enriched += 1
|
||||
except Exception:
|
||||
logger.debug("Failed to store enriched content for item %d", item_id, exc_info=True)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.info("Article content backfill complete: %d/%d items enriched", enriched, len(candidates))
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
"""Internal event store service with CalDAV push sync."""
|
||||
"""Internal event store service with CalDAV push sync.
|
||||
|
||||
Storage model: an event is anchored at ``start_dt`` and has an optional
|
||||
``duration_minutes``. The end of the event is *derived* via
|
||||
``Event.end_dt`` (a Python property), never stored. Callers may still
|
||||
pass ``end_dt`` on writes for ergonomic compatibility — the service
|
||||
converts to ``duration_minutes`` internally. This rules out the entire
|
||||
"end before start" bug class structurally (Fable #160 / migration
|
||||
0043). Open-ended events use ``duration_minutes = None``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -7,7 +16,7 @@ import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
@@ -15,11 +24,56 @@ from fabledassistant.models.event import Event
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_duration(
|
||||
*,
|
||||
start_dt: datetime,
|
||||
end_dt: datetime | None,
|
||||
duration_minutes: int | None,
|
||||
) -> int | None:
|
||||
"""Reduce (end_dt, duration_minutes) inputs to a single canonical
|
||||
``duration_minutes`` value.
|
||||
|
||||
Resolution order:
|
||||
1. If ``duration_minutes`` is explicit, use it (validate >= 0).
|
||||
If ``end_dt`` is also given, validate the two agree.
|
||||
2. Otherwise, derive from ``end_dt - start_dt``.
|
||||
3. Otherwise None (point event with no end).
|
||||
|
||||
Raises ``ValueError`` for any invalid combination — duration < 0,
|
||||
end_dt < start_dt, or end_dt and duration_minutes inconsistent.
|
||||
"""
|
||||
if duration_minutes is not None:
|
||||
if duration_minutes < 0:
|
||||
raise ValueError(
|
||||
f"duration_minutes must be >= 0, got {duration_minutes}"
|
||||
)
|
||||
if end_dt is not None:
|
||||
expected = int((end_dt - start_dt).total_seconds() // 60)
|
||||
if expected != duration_minutes:
|
||||
raise ValueError(
|
||||
f"end_dt ({end_dt.isoformat()}) implies "
|
||||
f"{expected} minutes but duration_minutes={duration_minutes} "
|
||||
f"was passed; pass only one or make them agree."
|
||||
)
|
||||
return duration_minutes
|
||||
if end_dt is not None:
|
||||
delta_seconds = (end_dt - start_dt).total_seconds()
|
||||
if delta_seconds < 0:
|
||||
raise ValueError(
|
||||
f"end_dt ({end_dt.isoformat()}) must be at or after "
|
||||
f"start_dt ({start_dt.isoformat()}); pass end_dt=None "
|
||||
f"or omit it for point events."
|
||||
)
|
||||
return int(delta_seconds // 60)
|
||||
return None
|
||||
|
||||
|
||||
async def create_event(
|
||||
user_id: int,
|
||||
title: str,
|
||||
start_dt: datetime,
|
||||
end_dt: datetime | None = None,
|
||||
duration_minutes: int | None = None,
|
||||
all_day: bool = False,
|
||||
description: str = "",
|
||||
location: str = "",
|
||||
@@ -27,12 +81,25 @@ async def create_event(
|
||||
recurrence: str | None = None,
|
||||
project_id: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
# CalDAV-only fields (not stored in DB, forwarded to push)
|
||||
# ``duration`` is a legacy alias kept for the calendar tool layer
|
||||
# and CalDAV pass-through callers; promotes to duration_minutes
|
||||
# when duration_minutes isn't otherwise specified.
|
||||
duration: int | None = None,
|
||||
attendees: list[str] | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> Event:
|
||||
"""Create an event in the DB, then fire a CalDAV push task."""
|
||||
"""Create an event in the DB, then fire a CalDAV push task.
|
||||
|
||||
Either ``end_dt`` or ``duration_minutes`` may be supplied; the
|
||||
service converts to ``duration_minutes`` internally. Raises
|
||||
``ValueError`` on invalid combinations (negative duration, end
|
||||
before start, end/duration disagreement).
|
||||
"""
|
||||
if duration is not None and duration_minutes is None:
|
||||
duration_minutes = duration
|
||||
duration_minutes = _normalize_duration(
|
||||
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
|
||||
)
|
||||
uid = str(uuid.uuid4())
|
||||
async with async_session() as session:
|
||||
event = Event(
|
||||
@@ -40,7 +107,7 @@ async def create_event(
|
||||
uid=uid,
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=duration_minutes,
|
||||
all_day=all_day,
|
||||
description=description,
|
||||
location=location,
|
||||
@@ -54,7 +121,7 @@ async def create_event(
|
||||
await session.refresh(event)
|
||||
|
||||
extra_fields = {
|
||||
"duration": duration,
|
||||
"duration": duration_minutes,
|
||||
"reminder_minutes": reminder_minutes,
|
||||
"attendees": attendees,
|
||||
"calendar_name": calendar_name,
|
||||
@@ -80,66 +147,74 @@ async def list_events(
|
||||
"""List events for user_id that overlap [date_from, date_to].
|
||||
|
||||
Recurring events (with an RRULE recurrence string) are expanded into
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as Event.to_dict()).
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as ``Event.to_dict()``).
|
||||
|
||||
Filtering strategy: a coarse SQL prefilter (events that start on or
|
||||
before ``date_to``), then refine in Python using the event's derived
|
||||
end (``start_dt + duration_minutes``). Doing the end-of-event math
|
||||
in SQL would require Postgres-specific interval arithmetic; the
|
||||
Python-side refinement is a few row-loops over a small per-user
|
||||
result set, which is fine for personal-scale data and avoids
|
||||
coupling the query to a specific dialect.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
# Match strategy:
|
||||
# - Recurring events: fetch all, expand via rrule below.
|
||||
# - Non-recurring with an end_dt: standard overlap — starts before
|
||||
# date_to and ends after date_from.
|
||||
# - Non-recurring with no end_dt: treat as a point event at
|
||||
# start_dt, include only if start_dt falls within the window.
|
||||
# (Previously this branch matched any event with a null end_dt,
|
||||
# returning all past events as "happening today".)
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.user_id == user_id,
|
||||
or_(
|
||||
Event.recurrence.isnot(None),
|
||||
and_(
|
||||
Event.recurrence.is_(None),
|
||||
Event.start_dt <= date_to,
|
||||
or_(
|
||||
Event.end_dt >= date_from,
|
||||
and_(
|
||||
Event.end_dt.is_(None),
|
||||
Event.start_dt >= date_from,
|
||||
),
|
||||
),
|
||||
),
|
||||
Event.start_dt <= date_to,
|
||||
),
|
||||
).order_by(Event.start_dt)
|
||||
)
|
||||
.order_by(Event.start_dt)
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
items: list[dict] = []
|
||||
for event in events:
|
||||
if not event.recurrence:
|
||||
items.append(event.to_dict())
|
||||
if event.recurrence:
|
||||
duration = (
|
||||
timedelta(minutes=event.duration_minutes)
|
||||
if event.duration_minutes is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
# Fall back to canonical event row; still apply the
|
||||
# window check so a far-future canonical row doesn't
|
||||
# leak into today's list.
|
||||
if date_from <= event.start_dt <= date_to:
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
continue
|
||||
|
||||
# Expand recurring event occurrences within [date_from, date_to]
|
||||
duration = (event.end_dt - event.start_dt) if event.end_dt else None
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
# Ensure occurrence is UTC-aware
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
# Non-recurring: refine the coarse prefilter in Python using the
|
||||
# derived end_dt. A point event (duration None) is included when
|
||||
# its start is at or after date_from. A timed event is included
|
||||
# when its end is at or after date_from.
|
||||
derived_end = event.end_dt
|
||||
if derived_end is None:
|
||||
if event.start_dt >= date_from:
|
||||
items.append(event.to_dict())
|
||||
else:
|
||||
if derived_end >= date_from:
|
||||
items.append(event.to_dict())
|
||||
|
||||
items.sort(key=lambda x: x["start_dt"])
|
||||
return items
|
||||
@@ -173,7 +248,13 @@ async def search_events(
|
||||
|
||||
|
||||
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
"""Partial update. Returns updated event or None if not found."""
|
||||
"""Partial update. Returns updated event or None if not found.
|
||||
|
||||
Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for
|
||||
agreement). The service converts to ``duration_minutes`` before
|
||||
persisting; ``end_dt`` is never stored. Raises ``ValueError`` for
|
||||
invalid combinations against the post-update state.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
||||
@@ -182,10 +263,39 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
if event is None:
|
||||
return None
|
||||
old_title = event.title # capture before mutation for CalDAV lookup
|
||||
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
|
||||
"location", "color", "recurrence", "project_id", "reminder_minutes"}
|
||||
# Nullable fields that callers can explicitly set to None to clear
|
||||
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"}
|
||||
|
||||
# Resolve any end_dt/duration_minutes inputs against the
|
||||
# post-update start_dt. If neither is in the patch, leave the
|
||||
# existing duration_minutes alone.
|
||||
post_update_start = (
|
||||
fields["start_dt"]
|
||||
if fields.get("start_dt") is not None
|
||||
else event.start_dt
|
||||
)
|
||||
if "end_dt" in fields or "duration_minutes" in fields:
|
||||
new_end = fields.pop("end_dt", None)
|
||||
new_duration = fields.pop("duration_minutes", None)
|
||||
# If end_dt is in the patch but explicitly None, that's a
|
||||
# clear → duration_minutes = None. Same shape duration_minutes=None.
|
||||
if new_end is None and new_duration is None:
|
||||
fields["duration_minutes"] = None
|
||||
else:
|
||||
fields["duration_minutes"] = _normalize_duration(
|
||||
start_dt=post_update_start,
|
||||
end_dt=new_end,
|
||||
duration_minutes=new_duration,
|
||||
)
|
||||
|
||||
allowed = {
|
||||
"title", "start_dt", "duration_minutes", "all_day",
|
||||
"description", "location", "color", "recurrence",
|
||||
"project_id", "reminder_minutes",
|
||||
}
|
||||
# Nullable fields callers can explicitly clear by passing None
|
||||
nullable = {
|
||||
"duration_minutes", "recurrence", "project_id",
|
||||
"reminder_minutes",
|
||||
}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and (value is not None or key in nullable):
|
||||
setattr(event, key, value)
|
||||
@@ -255,11 +365,12 @@ async def _push_create(event: Event, user_id: int, extra: dict) -> None:
|
||||
)
|
||||
if not await is_caldav_configured(user_id):
|
||||
return
|
||||
derived_end = event.end_dt # property: start + duration_minutes
|
||||
await caldav_create(
|
||||
user_id=user_id,
|
||||
title=event.title,
|
||||
start=event.start_dt.isoformat(),
|
||||
end=event.end_dt.isoformat() if event.end_dt else None,
|
||||
end=derived_end.isoformat() if derived_end else None,
|
||||
description=event.description or None,
|
||||
location=event.location or None,
|
||||
all_day=event.all_day,
|
||||
@@ -296,12 +407,13 @@ async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
|
||||
return
|
||||
# Use old_title so CalDAV can find the event even if the title was changed
|
||||
query_title = old_title or event.title
|
||||
derived_end = event.end_dt
|
||||
await caldav_update(
|
||||
user_id=user_id,
|
||||
query=query_title,
|
||||
title=event.title,
|
||||
start=event.start_dt.isoformat(),
|
||||
end=event.end_dt.isoformat() if event.end_dt else None,
|
||||
end=derived_end.isoformat() if derived_end else None,
|
||||
description=event.description or None,
|
||||
location=event.location or None,
|
||||
)
|
||||
|
||||
@@ -37,84 +37,6 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
|
||||
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
|
||||
|
||||
|
||||
async def _maybe_save_article_discussion_note(
|
||||
user_id: int, conv_id: int, reply_content: str,
|
||||
) -> None:
|
||||
"""Persist a seeded article-discussion's first reply as a Note.
|
||||
|
||||
Fires after ``run_generation`` completes. Looks for a synthetic
|
||||
read_article seed message on the conversation; if found AND the linked
|
||||
``rss_items`` row has no ``discussion_note_id`` yet, saves ``reply_content``
|
||||
as a Note, tags it, and writes the backlink. Subsequent discuss clicks on
|
||||
the same article are a no-op (already linked).
|
||||
|
||||
Failures are logged and swallowed — the chat UI should never break because
|
||||
Note persistence hit a snag.
|
||||
"""
|
||||
try:
|
||||
if not reply_content or not reply_content.strip():
|
||||
return
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models.conversation import Message as _Message
|
||||
from fabledassistant.models.rss_feed import RssItem as _RssItem
|
||||
from fabledassistant.services.notes import create_note
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(_Message)
|
||||
.where(_Message.conversation_id == conv_id)
|
||||
.order_by(_Message.id.asc())
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
seed_meta = None
|
||||
for m in messages:
|
||||
meta = m.msg_metadata or {}
|
||||
if meta.get("article_seed") and meta.get("rss_item_id"):
|
||||
seed_meta = meta
|
||||
break
|
||||
if seed_meta is None:
|
||||
return
|
||||
item_id = int(seed_meta["rss_item_id"])
|
||||
item = await session.get(_RssItem, item_id)
|
||||
if item is None or item.discussion_note_id is not None:
|
||||
return
|
||||
article_title = (item.title or "Untitled article").strip()
|
||||
article_url = item.url
|
||||
article_topics = list(item.topics or [])
|
||||
|
||||
note_title = f"Article: {article_title}"[:200]
|
||||
body_parts = [f"**Source:** {article_url}"] if article_url else []
|
||||
body_parts.append(reply_content.strip())
|
||||
note_body = "\n\n".join(body_parts)
|
||||
tags = ["article-summary"] + [t for t in article_topics if t]
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=note_title,
|
||||
body=note_body,
|
||||
tags=tags,
|
||||
entity_meta={
|
||||
"source": "article_discussion",
|
||||
"rss_item_id": item_id,
|
||||
"url": article_url,
|
||||
"conversation_id": conv_id,
|
||||
},
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(_RssItem, item_id)
|
||||
if fresh is not None and fresh.discussion_note_id is None:
|
||||
fresh.discussion_note_id = note.id
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Saved article-discussion summary as note %d for rss_item %d (conv %d)",
|
||||
note.id, item_id, conv_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist article-discussion note for conv %d",
|
||||
conv_id, exc_info=True,
|
||||
)
|
||||
|
||||
# Human-readable labels for each tool, shown in the status indicator
|
||||
_TOOL_LABELS: dict[str, str] = {
|
||||
"create_note": "Creating note/task",
|
||||
@@ -254,8 +176,15 @@ async def run_generation(
|
||||
|
||||
buf.append_event("status", {"status": "Building context..."})
|
||||
|
||||
# Phase 1: Resolve the tools list for this user.
|
||||
tools = await get_tools_for_user(user_id)
|
||||
# Phase 1: Resolve the tools list for this user, scoped to conversation type.
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Conversation as _Conversation
|
||||
async with _async_session() as _sess:
|
||||
_conv = await _sess.get(_Conversation, conv_id)
|
||||
_conversation_type = (
|
||||
_conv.conversation_type if _conv and _conv.conversation_type else "chat"
|
||||
)
|
||||
tools = await get_tools_for_user(user_id, conversation_type=_conversation_type)
|
||||
|
||||
logger.info(
|
||||
"Starting generation for conv %d: model=%s, tools=%d",
|
||||
@@ -586,28 +515,16 @@ async def run_generation(
|
||||
msg_count = len(non_system)
|
||||
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
|
||||
|
||||
# Persist article-discussion seed conversations as a Note on their
|
||||
# first assistant reply. This makes "Discuss" summaries part of RAG
|
||||
# so the knowledge base stops being amnesiac about articles the user
|
||||
# has already engaged with. The hook detects a seeded conversation by
|
||||
# finding a synthetic read_article assistant message whose
|
||||
# msg_metadata carries ``article_seed: True`` and whose rss_items row
|
||||
# has no discussion_note_id yet. Fire-and-forget so the done event
|
||||
# lands immediately.
|
||||
asyncio.create_task(_maybe_save_article_discussion_note(
|
||||
user_id, conv_id, buf.content_so_far,
|
||||
))
|
||||
|
||||
if should_gen_title:
|
||||
# Feed the title model the *raw* conversation turns only — never
|
||||
# the post-build_context ``messages`` list. ``build_context``
|
||||
# prepends RAG snippets, RSS excerpts, URL content, and briefing
|
||||
# article dumps INTO the user message string itself, so filtering
|
||||
# by role="user" downstream still surfaces that noise as the
|
||||
# "user's message". That pollution caused wildly-wrong titles
|
||||
# (bug #109) — the small background model was staring at article
|
||||
# excerpts instead of what the user actually typed. Pass the
|
||||
# original history + the raw user_content + the assistant reply.
|
||||
# prepends RAG snippets and URL content INTO the user message
|
||||
# string itself, so filtering by role="user" downstream still
|
||||
# surfaces that noise as the "user's message". That pollution
|
||||
# caused wildly-wrong titles (bug #109) — the small background
|
||||
# model was staring at article excerpts instead of what the user
|
||||
# actually typed. Pass the original history + the raw user_content
|
||||
# + the assistant reply.
|
||||
title_messages: list[dict] = [
|
||||
{"role": m["role"], "content": m.get("content") or ""}
|
||||
for m in history
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""System prompt + ambient context injection for journal conversations.
|
||||
|
||||
Distinct from the chat pipeline (services/llm.py) in three ways:
|
||||
1. New persona: warm, curious listener; not a task manager.
|
||||
2. Calibration rules: ask before structural changes; record_moment freely.
|
||||
3. Auto-injects last ~48h of moments for ambient continuity (no notes-RAG).
|
||||
|
||||
Notes-RAG auto-injection MUST be disabled for journal sessions — the
|
||||
journal's ambient context replaces it. Failing to disable would let notes
|
||||
leak into journal sessions, violating the design's isolation invariant.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from fabledassistant.services.journal_search import search_journal
|
||||
from fabledassistant.services.user_profile import build_profile_context
|
||||
|
||||
JOURNAL_PERSONA = (
|
||||
"You are the user's assistant. They've opened their journal — a day-anchored "
|
||||
"conversation surface where they record their day. Your primary job is to "
|
||||
"CAPTURE what they share, not to advise on it. Treat journal mode as a quiet "
|
||||
"thinking-companion surface: record the beat, optionally ask one short "
|
||||
"follow-up that doesn't presume they want help, and let the user lead. "
|
||||
"Use tools to act on their behalf when they ask, not when you think they "
|
||||
"might find it useful. "
|
||||
"The day's prep message at the top of the conversation is your context — "
|
||||
"build on it, don't restate it."
|
||||
)
|
||||
|
||||
JOURNAL_CALIBRATION = """\
|
||||
JOURNAL-SPECIFIC TOOL GUIDANCE:
|
||||
|
||||
PEOPLE / PLACES — ask before creating new entries.
|
||||
- If the user mentions a name you don't already know about, ASK them in plain
|
||||
language ("Who's Sarah to you?") and WAIT for the reply before calling
|
||||
save_person or save_place.
|
||||
- For ambiguous references (multiple matches in their existing people/places),
|
||||
ask which one. Never guess.
|
||||
- For unambiguous references to people they've already established, no need
|
||||
to ask — proceed normally.
|
||||
|
||||
MOMENTS — recording them is your primary job, not a "nice to have."
|
||||
|
||||
After every substantive user message, BEFORE you compose your reply,
|
||||
check: did the user describe ANY of these?
|
||||
- An event that happened ("I went grocery shopping")
|
||||
- An encounter with a person ("had coffee with Sarah")
|
||||
- A decision ("I'm going to switch jobs")
|
||||
- An observation about themselves or the world ("the new place is loud")
|
||||
- A plan or commitment ("watching a show with Victoria tonight")
|
||||
- A feeling or state ("I'm tired", "feeling decompressed")
|
||||
- A small accomplishment or change they made ("installed the new AP")
|
||||
|
||||
If the answer is YES to ANY of those — CALL record_moment FIRST, before
|
||||
composing your reply. This is not optional. The journal exists to capture
|
||||
these beats; if you skip the call, the beat is lost.
|
||||
|
||||
Multiple distinct beats in one message → multiple record_moment calls,
|
||||
one per beat.
|
||||
|
||||
MOMENT PHRASING — write it the way the user would jot it themselves.
|
||||
First-person or imperative, never third-person observer voice.
|
||||
- GOOD: "Restaging Docker on the Bedford swarm; one Windows node had
|
||||
network breakage."
|
||||
- GOOD: "Appointment this Friday — details TBD."
|
||||
- GOOD: "Coffee with Sarah; she's hiring."
|
||||
- BAD: "The user mentioned having an appointment this Friday but
|
||||
hasn't provided details yet."
|
||||
- BAD: "User reports Docker swarm restage in progress."
|
||||
Strip "the user…" / "user mentioned…" / "user is…" framings entirely.
|
||||
|
||||
MOMENT ENTITY LINKING — be conservative.
|
||||
- Only attach a `task_titles` link when the user *explicitly references
|
||||
that task* in the message. Do NOT link to a task just because it's
|
||||
in the prep context or the only task currently open.
|
||||
- Only attach `place_names` you can ground in something the user
|
||||
actually said. Generic placeholders like "work" / "home" / "office"
|
||||
are NOT places — drop them and let the user name the real one if it
|
||||
matters.
|
||||
|
||||
WHEN LINKING ENTITIES: use the *_names parameters (person_names,
|
||||
place_names, task_titles, note_titles). Server resolves them to IDs by
|
||||
lookup. Do NOT pass *_ids unless you have an exact ID returned from
|
||||
another tool call in this same turn. Never invent IDs.
|
||||
|
||||
The ONLY messages where you skip record_moment are purely meta-conversational
|
||||
ones — about the journal itself or about a prior tool result ("thanks",
|
||||
"no priority needed", "can you also add X to that one", "I meant tasks not
|
||||
notes"). Those aren't journal beats; they're chat about the chat.
|
||||
|
||||
STATE-CHANGING TOOLS — use the confirmation flow.
|
||||
- update_task / update_note that change state (status, completion, deletion)
|
||||
follow the standard confirmation pattern: pass `confirmed=false` first; the
|
||||
frontend shows a confirm UI; call again with `confirmed=true` after the
|
||||
user confirms.
|
||||
- Pure-read tools (list_tasks, search_notes, search_journal, get_weather, etc.)
|
||||
don't need confirmation.
|
||||
|
||||
OTHER:
|
||||
- Do NOT call set_rag_scope. The journal scope is implicit.
|
||||
- Notes are not auto-retrieved here. If you need to reference a note, call
|
||||
search_notes explicitly.
|
||||
|
||||
RESPONSE STYLE:
|
||||
- Don't apologize for the user's feelings ("I'm sorry you're feeling…"). Engage
|
||||
with what they said directly.
|
||||
- Don't produce multi-option menus ("1. Show your calendar 2. List your tasks
|
||||
3. ..."). They feel like a help-desk bot. Ask one specific follow-up or take
|
||||
one specific action.
|
||||
- Don't repeat a prior reply verbatim. If the user circles back on a theme,
|
||||
pick a specific concrete detail from the new message to react to.
|
||||
- Match the user's length. Short message → short reply. Don't pad.
|
||||
- DON'T offer troubleshooting steps, checklists, or generic process advice
|
||||
for the user's work unless they explicitly ask. When the user is logging
|
||||
what they're doing, they want to be heard, not coached. A statement like
|
||||
"I'm prepping for an ISP migration" should be acknowledged and recorded —
|
||||
not met with "Are you handling the network configuration yourself? Are
|
||||
there checks you need to do first?" If a follow-up would presume they
|
||||
want help, drop it.
|
||||
- No emojis. The journal is a thinking-companion surface; emojis read as
|
||||
chat-bot warmth that's out of register.
|
||||
"""
|
||||
|
||||
PHASE_GREETINGS = {
|
||||
"morning": "Morning. What's the day looking like for you?",
|
||||
"midday": "How's it going so far?",
|
||||
"evening": "Wrapping up — how'd the day shake out?",
|
||||
}
|
||||
|
||||
|
||||
def determine_phase(
|
||||
*,
|
||||
now_local: datetime.datetime,
|
||||
day_rollover_hour: int = 4,
|
||||
morning_end_hour: int = 12,
|
||||
midday_end_hour: int = 18,
|
||||
) -> str:
|
||||
"""Return 'morning' | 'midday' | 'evening' for a local datetime."""
|
||||
h = now_local.hour
|
||||
if h < day_rollover_hour:
|
||||
return "evening"
|
||||
if h < morning_end_hour:
|
||||
return "morning"
|
||||
if h < midday_end_hour:
|
||||
return "midday"
|
||||
return "evening"
|
||||
|
||||
|
||||
def phase_greeting(phase: str) -> str:
|
||||
return PHASE_GREETINGS.get(phase, PHASE_GREETINGS["morning"])
|
||||
|
||||
|
||||
async def build_journal_system_prompt(
|
||||
*,
|
||||
user_id: int,
|
||||
day_date: datetime.date,
|
||||
user_timezone: str,
|
||||
) -> str:
|
||||
"""Static-then-dynamic system prompt.
|
||||
|
||||
Static prefix (persona + calibration) is identical on every request,
|
||||
preserving Ollama KV cache. Dynamic suffix changes per-day.
|
||||
"""
|
||||
static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}"
|
||||
|
||||
today_iso = day_date.isoformat()
|
||||
# Include the day-of-week explicitly. LLMs are unreliable at deriving
|
||||
# weekday names from ISO dates, which causes "this Friday" / "next
|
||||
# Monday" to land on the wrong calendar day.
|
||||
weekday = day_date.strftime("%A")
|
||||
tz_block = f"Today is {weekday}, {today_iso} ({user_timezone})."
|
||||
|
||||
profile_context = await build_profile_context(user_id)
|
||||
profile_section = f"\n\n{profile_context}" if profile_context else ""
|
||||
|
||||
ambient = await _ambient_moments_block(user_id=user_id, day_date=day_date)
|
||||
ambient_section = (
|
||||
f"\n\nRECENT JOURNAL CONTEXT (last 48h):\n{ambient}" if ambient else ""
|
||||
)
|
||||
|
||||
return f"{static_block}\n\n{tz_block}{profile_section}{ambient_section}"
|
||||
|
||||
|
||||
async def _ambient_moments_block(
|
||||
*, user_id: int, day_date: datetime.date
|
||||
) -> str:
|
||||
"""Render last 48h of moments as a compact text block.
|
||||
|
||||
Capped at 20 moments / 1500 chars total. Distinct from RAG retrieval.
|
||||
"""
|
||||
moments = await search_journal(
|
||||
user_id=user_id,
|
||||
date_from=day_date - datetime.timedelta(days=2),
|
||||
date_to=day_date,
|
||||
limit=20,
|
||||
)
|
||||
if not moments:
|
||||
return ""
|
||||
|
||||
lines: list[str] = []
|
||||
total_chars = 0
|
||||
for m in moments:
|
||||
line = f"- [{m['occurred_at']}] {m['content']}"
|
||||
if total_chars + len(line) > 1500:
|
||||
break
|
||||
lines.append(line)
|
||||
total_chars += len(line)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Daily prep generator for the Journal.
|
||||
|
||||
Runs once per day per user (scheduled, or lazy on first journal-open of a
|
||||
new day). Two phases:
|
||||
|
||||
1. Gather structured data (tasks/events/weather/projects/recent moments/
|
||||
open threads) — deterministic, no LLM call.
|
||||
2. Hand the structured data to the LLM and ask it for a direct, informative
|
||||
conversational opener — flowing prose, briefing-style. Result is persisted
|
||||
as the first *assistant* message in today's journal Conversation, so it
|
||||
renders with the standard Illuminated Transcript bubble styling alongside
|
||||
the rest of the conversation.
|
||||
|
||||
The structured data is preserved on ``Message.msg_metadata.sections`` for
|
||||
provenance and future tooling.
|
||||
|
||||
Message shape:
|
||||
role: 'assistant'
|
||||
content: <prose opener>
|
||||
msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import Conversation, Message, async_session
|
||||
from fabledassistant.services.events import list_events
|
||||
from fabledassistant.services.journal_search import search_journal
|
||||
from fabledassistant.services.notes import list_notes
|
||||
from fabledassistant.services.projects import list_projects
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.services.weather import get_cached_weather_rows
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# How many days out from today an event needs to be before the prep treats
|
||||
# it as too far-future to surface. Catches recurring-event canonical rows
|
||||
# whose RRULE expansion missed (an `rrulestr` failure falls back to the
|
||||
# canonical event in `list_events`, which leaks far-future occurrences
|
||||
# into today's prep).
|
||||
_EVENT_PROXIMITY_DAYS = 7
|
||||
|
||||
|
||||
def _task_to_prep_dict(task, today: datetime.date) -> dict:
|
||||
"""Render a Note row as a prep-payload task entry, tagging overdue
|
||||
staleness when relevant so the prompt can frame it correctly."""
|
||||
d = {
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"status": task.status,
|
||||
"priority": task.priority,
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
}
|
||||
if task.due_date and task.due_date < today:
|
||||
d["days_overdue"] = (today - task.due_date).days
|
||||
return d
|
||||
|
||||
|
||||
def _filter_proximate_events(
|
||||
events: list[dict], *, day_date: datetime.date, user_tz: ZoneInfo
|
||||
) -> list[dict]:
|
||||
"""Drop events whose start_dt is more than ``_EVENT_PROXIMITY_DAYS``
|
||||
away from ``day_date`` in the user's local timezone.
|
||||
|
||||
Belt-and-suspenders against `list_events` returning a canonical
|
||||
far-future event (e.g. when RRULE expansion fails and the loop falls
|
||||
back to the original event row, regardless of date). The user
|
||||
observed "Birthday — 2026-09-29 (FREQ=YEARLY)" surfacing in every
|
||||
daily prep 5 months out; this filter keeps the prep proximate.
|
||||
"""
|
||||
proximate: list[dict] = []
|
||||
for e in events:
|
||||
raw = e.get("start_dt") or ""
|
||||
try:
|
||||
start_dt = datetime.datetime.fromisoformat(
|
||||
raw.replace("Z", "+00:00") if isinstance(raw, str) else ""
|
||||
)
|
||||
local_date = start_dt.astimezone(user_tz).date()
|
||||
delta = abs((local_date - day_date).days)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
# Unparseable date — keep the event rather than suppress real data.
|
||||
proximate.append(e)
|
||||
continue
|
||||
if delta <= _EVENT_PROXIMITY_DAYS:
|
||||
proximate.append(e)
|
||||
else:
|
||||
logger.info(
|
||||
"daily_prep: dropping non-proximate event %r start_local=%s "
|
||||
"(%d days from day %s)",
|
||||
e.get("title"), local_date.isoformat(), delta, day_date.isoformat(),
|
||||
)
|
||||
return proximate
|
||||
|
||||
|
||||
async def gather_daily_sections(
|
||||
*,
|
||||
user_id: int,
|
||||
day_date: datetime.date,
|
||||
user_timezone: str,
|
||||
) -> dict:
|
||||
"""Gather all daily-prep sections and return them as a dict.
|
||||
|
||||
Pure data fetching — no LLM call. Each section degrades to an empty
|
||||
list/dict on failure so the caller always gets a complete shape.
|
||||
|
||||
Tasks are returned in three explicit buckets so the prompt can frame
|
||||
overdue items correctly (instead of calling them "due today" — the
|
||||
pre-2026-04-29 behavior, before this rewrite).
|
||||
"""
|
||||
sections: dict = {}
|
||||
|
||||
next_day = day_date + datetime.timedelta(days=1)
|
||||
upcoming_end = day_date + datetime.timedelta(days=8)
|
||||
try:
|
||||
due_today_rows, _ = await list_notes(
|
||||
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
||||
due_after=day_date, due_before=next_day,
|
||||
limit=20, sort="due_date", order="asc",
|
||||
)
|
||||
upcoming_rows, _ = await list_notes(
|
||||
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
||||
due_after=next_day, due_before=upcoming_end,
|
||||
limit=20, sort="due_date", order="asc",
|
||||
)
|
||||
overdue_rows, _ = await list_notes(
|
||||
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
||||
due_before=day_date,
|
||||
limit=20, sort="due_date", order="asc",
|
||||
)
|
||||
sections["tasks_due_today"] = [_task_to_prep_dict(t, day_date) for t in due_today_rows]
|
||||
sections["tasks_upcoming"] = [_task_to_prep_dict(t, day_date) for t in upcoming_rows]
|
||||
sections["tasks_overdue"] = [_task_to_prep_dict(t, day_date) for t in overdue_rows]
|
||||
# Backwards-compat alias for any consumers still reading sections["tasks"].
|
||||
# The combined view is more useful than the prior overdue-only behavior.
|
||||
sections["tasks"] = (
|
||||
sections["tasks_due_today"]
|
||||
+ sections["tasks_upcoming"]
|
||||
+ sections["tasks_overdue"]
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("daily_prep tasks section failed for user %d", user_id)
|
||||
sections["tasks_due_today"] = []
|
||||
sections["tasks_upcoming"] = []
|
||||
sections["tasks_overdue"] = []
|
||||
sections["tasks"] = []
|
||||
|
||||
try:
|
||||
try:
|
||||
user_tz = ZoneInfo(user_timezone)
|
||||
except Exception:
|
||||
logger.warning("daily_prep: invalid user_timezone %r — defaulting to UTC", user_timezone)
|
||||
user_tz = ZoneInfo("UTC")
|
||||
# Build the local-day window in the user's TZ, then convert to UTC
|
||||
# for the DB / RRULE expansion. A naive datetime here previously
|
||||
# caused rrule.between() to throw, falling back to the canonical
|
||||
# event row regardless of date — the source of stale recurring
|
||||
# events polluting every daily prep.
|
||||
day_start_local = datetime.datetime.combine(day_date, datetime.time.min, tzinfo=user_tz)
|
||||
day_end_local = datetime.datetime.combine(day_date, datetime.time.max, tzinfo=user_tz)
|
||||
day_start = day_start_local.astimezone(datetime.timezone.utc)
|
||||
day_end = day_end_local.astimezone(datetime.timezone.utc)
|
||||
all_events = await list_events(
|
||||
user_id=user_id,
|
||||
date_from=day_start,
|
||||
date_to=day_end,
|
||||
)
|
||||
sections["events"] = _filter_proximate_events(
|
||||
all_events, day_date=day_date, user_tz=user_tz,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("daily_prep events section failed for user %d", user_id)
|
||||
sections["events"] = []
|
||||
|
||||
try:
|
||||
# Lazy import: journal_scheduler imports this module for prep generation,
|
||||
# so a top-level import would cycle.
|
||||
from fabledassistant.services.journal_scheduler import get_journal_config
|
||||
cfg = await get_journal_config(user_id)
|
||||
valid_weather_keys = {
|
||||
key for key, loc in (cfg.get("locations") or {}).items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
}
|
||||
weather_rows = await get_cached_weather_rows(user_id, valid_weather_keys)
|
||||
sections["weather"] = [w.to_dict() for w in weather_rows]
|
||||
except Exception:
|
||||
logger.exception("daily_prep weather section failed for user %d", user_id)
|
||||
sections["weather"] = []
|
||||
|
||||
try:
|
||||
projects = await list_projects(user_id=user_id, status="active")
|
||||
sections["projects"] = [
|
||||
{
|
||||
"id": p.id,
|
||||
"title": p.title,
|
||||
"auto_summary": p.auto_summary,
|
||||
}
|
||||
for p in projects[:5]
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("daily_prep projects section failed for user %d", user_id)
|
||||
sections["projects"] = []
|
||||
|
||||
try:
|
||||
sections["recent_moments"] = await search_journal(
|
||||
user_id=user_id,
|
||||
date_from=day_date - datetime.timedelta(days=3),
|
||||
date_to=day_date - datetime.timedelta(days=1),
|
||||
limit=10,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("daily_prep recent_moments section failed for user %d", user_id)
|
||||
sections["recent_moments"] = []
|
||||
|
||||
try:
|
||||
sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date)
|
||||
except Exception:
|
||||
logger.exception("daily_prep open_threads section failed for user %d", user_id)
|
||||
sections["open_threads"] = []
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
|
||||
"""Heuristic: moments from the last 7 days that look unresolved.
|
||||
|
||||
Treated as 'unresolved' when they have no linked tasks/notes and aren't
|
||||
pinned. Starting heuristic — refine empirically.
|
||||
"""
|
||||
candidates = await search_journal(
|
||||
user_id=user_id,
|
||||
date_from=day_date - datetime.timedelta(days=7),
|
||||
date_to=day_date - datetime.timedelta(days=1),
|
||||
limit=50,
|
||||
)
|
||||
return [
|
||||
m for m in candidates
|
||||
if not m.get("task_ids")
|
||||
and not m.get("note_ids")
|
||||
and not m.get("pinned")
|
||||
]
|
||||
|
||||
|
||||
def _render_task_line(t: dict, *, include_due: bool, include_overdue: bool) -> str:
|
||||
line = f" - {t.get('title', '?')}"
|
||||
if include_overdue and t.get("days_overdue"):
|
||||
line += f" (due {t['due_date']}, {t['days_overdue']} days ago)"
|
||||
elif include_due and t.get("due_date"):
|
||||
line += f" (due {t['due_date']})"
|
||||
if t.get("priority") and t["priority"] not in (None, "none"):
|
||||
line += f" [{t['priority']} priority]"
|
||||
if t.get("status") == "in_progress":
|
||||
line += " [in progress]"
|
||||
return line
|
||||
|
||||
|
||||
def _render_sections_for_prompt(sections: dict) -> str:
|
||||
"""Render the gathered sections as a structured plain-text block for the LLM."""
|
||||
lines: list[str] = []
|
||||
|
||||
due_today = sections.get("tasks_due_today") or []
|
||||
upcoming = sections.get("tasks_upcoming") or []
|
||||
overdue = sections.get("tasks_overdue") or []
|
||||
if due_today:
|
||||
lines.append("TASKS DUE TODAY:")
|
||||
for t in due_today[:8]:
|
||||
lines.append(_render_task_line(t, include_due=False, include_overdue=False))
|
||||
lines.append("")
|
||||
if upcoming:
|
||||
lines.append("UPCOMING TASKS (next 7 days):")
|
||||
for t in upcoming[:8]:
|
||||
lines.append(_render_task_line(t, include_due=True, include_overdue=False))
|
||||
lines.append("")
|
||||
if overdue:
|
||||
lines.append("OVERDUE TASKS (still on the list, not currently due):")
|
||||
for t in overdue[:8]:
|
||||
lines.append(_render_task_line(t, include_due=False, include_overdue=True))
|
||||
lines.append("")
|
||||
|
||||
events = sections.get("events") or []
|
||||
if events:
|
||||
lines.append("CALENDAR EVENTS TODAY:")
|
||||
for e in events[:8]:
|
||||
title = e.get("title", "Untitled")
|
||||
when = e.get("start_dt", "?")
|
||||
location = e.get("location") or ""
|
||||
line = f" - {title} at {when}"
|
||||
if location:
|
||||
line += f" ({location})"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
weather = sections.get("weather") or []
|
||||
if weather:
|
||||
lines.append("WEATHER:")
|
||||
for w in weather:
|
||||
label = w.get("location_label") or w.get("location_key") or "Location"
|
||||
forecast_json = w.get("forecast_json") or {}
|
||||
daily = forecast_json.get("daily") or {}
|
||||
today_max = (daily.get("temperature_2m_max") or [None])[0]
|
||||
today_min = (daily.get("temperature_2m_min") or [None])[0]
|
||||
precip = (daily.get("precipitation_probability_max") or [None])[0]
|
||||
bits = [label]
|
||||
if today_max is not None and today_min is not None:
|
||||
bits.append(f"high {today_max}° / low {today_min}°")
|
||||
if precip is not None:
|
||||
bits.append(f"{precip}% chance of precipitation")
|
||||
lines.append(" - " + ", ".join(bits))
|
||||
lines.append("")
|
||||
|
||||
projects = sections.get("projects") or []
|
||||
if projects:
|
||||
lines.append("ACTIVE PROJECTS:")
|
||||
for p in projects[:5]:
|
||||
line = f" - {p.get('title', '?')}"
|
||||
if p.get("auto_summary"):
|
||||
summary = p["auto_summary"][:160]
|
||||
line += f" — {summary}"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
recent_moments = sections.get("recent_moments") or []
|
||||
if recent_moments:
|
||||
lines.append("RECENT JOURNAL MOMENTS (last few days):")
|
||||
for m in recent_moments[:8]:
|
||||
day = m.get("day_date", "?")
|
||||
content = (m.get("content") or "").strip()
|
||||
lines.append(f" - [{day}] {content}")
|
||||
lines.append("")
|
||||
|
||||
open_threads = sections.get("open_threads") or []
|
||||
if open_threads:
|
||||
lines.append("OPEN THREADS (mentioned recently but not resolved):")
|
||||
for m in open_threads[:5]:
|
||||
day = m.get("day_date", "?")
|
||||
content = (m.get("content") or "").strip()
|
||||
lines.append(f" - [{day}] {content}")
|
||||
lines.append("")
|
||||
|
||||
if not lines:
|
||||
return "(No data for today — quiet morning.)"
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
_PREP_SYSTEM_PROMPT = (
|
||||
"You are briefing the user on their day. Direct and informative — tell them what's "
|
||||
"actually on their plate so they can step into the day with a clear picture.\n\n"
|
||||
"Rules:\n"
|
||||
"- LEAD with the practical data: tasks due today, calendar events, weather.\n"
|
||||
"- Be specific and concrete. Use real task titles, event times, temperatures, "
|
||||
"precipitation chances. Don't paraphrase data into vague summaries.\n"
|
||||
"- Write in flowing sentences — no markdown, no bullet points, no headers — but "
|
||||
"keep the prose factual and useful, not sentimental.\n"
|
||||
"- 4 to 7 sentences total. Tight. No padding, no flowery openings, no \"Good morning\" "
|
||||
"greetings unless the actual content warrants two clauses' worth.\n"
|
||||
"- TASK BUCKETS — three sections may appear: TASKS DUE TODAY, UPCOMING TASKS, "
|
||||
"OVERDUE TASKS. Lead with TASKS DUE TODAY when present. Do NOT call overdue items "
|
||||
"\"due today\" — they aren't. When OVERDUE TASKS appears, surface it with the "
|
||||
"staleness duration (\"still on the list 68 days\") and frame it as something to "
|
||||
"revisit, not as today's work. If the only data is overdue, lead with it but "
|
||||
"frame it as a backlog reminder.\n"
|
||||
"- If RECENT JOURNAL MOMENTS or OPEN THREADS are present, mention one or two BRIEFLY "
|
||||
"at the end as context — not as the lead. Skip them if nothing notable.\n"
|
||||
"- Close with one short invitation to journal: \"What's on your mind?\", "
|
||||
"\"Anything to set down?\", \"How's the morning shaping up?\" — pick one, keep it under 8 words.\n"
|
||||
"- Don't fabricate. Skip categories with no data; don't acknowledge their absence.\n"
|
||||
"- Voice is competent assistant briefing the user. Not a friend writing a letter."
|
||||
)
|
||||
|
||||
|
||||
def _fallback_prep_text(day_date: datetime.date) -> str:
|
||||
"""If the LLM call fails, return a minimal greeting so the user still sees something."""
|
||||
weekday = day_date.strftime("%A")
|
||||
return f"{weekday}, {day_date.isoformat()}. What's on your mind?"
|
||||
|
||||
|
||||
async def _generate_prep_prose(
|
||||
*,
|
||||
sections: dict,
|
||||
day_date: datetime.date,
|
||||
user_id: int,
|
||||
) -> str:
|
||||
"""Ask the LLM for a direct conversational journal opener built from the sections."""
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
|
||||
model = (await get_setting(user_id, "default_model", "")) or Config.OLLAMA_MODEL
|
||||
if not model:
|
||||
logger.warning("No LLM model configured for daily prep — using fallback text")
|
||||
return _fallback_prep_text(day_date)
|
||||
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
user_trigger = (
|
||||
f"Today is {day_date.strftime('%A, %B %-d, %Y')} ({day_date.isoformat()}).\n\n"
|
||||
f"Here is what I gathered for you:\n\n{rendered}\n\n"
|
||||
f"Write the opener for today's journal."
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _PREP_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_trigger},
|
||||
]
|
||||
|
||||
try:
|
||||
prose = await generate_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
max_tokens=400,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Daily prep prose generation failed for day %s", day_date)
|
||||
return _fallback_prep_text(day_date)
|
||||
|
||||
prose = (prose or "").strip()
|
||||
if not prose:
|
||||
logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date)
|
||||
return _fallback_prep_text(day_date)
|
||||
return prose
|
||||
|
||||
|
||||
async def ensure_daily_prep_message(
|
||||
*,
|
||||
user_id: int,
|
||||
day_date: datetime.date,
|
||||
user_timezone: str,
|
||||
force: bool = False,
|
||||
) -> Message:
|
||||
"""Get or create today's journal Conversation, then ensure the prep message exists.
|
||||
|
||||
The prep message is an *assistant* role message containing the prose opener,
|
||||
with the structured sections preserved on ``msg_metadata``. If a legacy
|
||||
system-role prep exists from an earlier version, it gets upgraded in place
|
||||
on the next call.
|
||||
|
||||
With ``force=True`` the prose is regenerated even when a prep already exists.
|
||||
Used by the manual /api/journal/trigger-prep endpoint.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date == day_date,
|
||||
)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if conv is None:
|
||||
conv = Conversation(
|
||||
user_id=user_id,
|
||||
conversation_type="journal",
|
||||
day_date=day_date,
|
||||
title=day_date.isoformat(),
|
||||
)
|
||||
session.add(conv)
|
||||
await session.flush()
|
||||
|
||||
# Find any existing prep (system or assistant role from any version).
|
||||
prep_stmt = select(Message).where(Message.conversation_id == conv.id)
|
||||
existing_prep = None
|
||||
for msg in (await session.execute(prep_stmt)).scalars():
|
||||
if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep":
|
||||
existing_prep = msg
|
||||
break
|
||||
|
||||
# If we already have an assistant-role prep with prose content and the
|
||||
# caller didn't ask to force regeneration, we're done.
|
||||
if (
|
||||
existing_prep
|
||||
and not force
|
||||
and existing_prep.role == "assistant"
|
||||
and (existing_prep.content or "").strip()
|
||||
):
|
||||
return existing_prep
|
||||
|
||||
sections = await gather_daily_sections(
|
||||
user_id=user_id, day_date=day_date, user_timezone=user_timezone
|
||||
)
|
||||
prose = await _generate_prep_prose(
|
||||
sections=sections, day_date=day_date, user_id=user_id
|
||||
)
|
||||
new_metadata = {"kind": "daily_prep", "sections": sections}
|
||||
|
||||
if existing_prep:
|
||||
# Upgrade in place: bump role, replace content + metadata.
|
||||
existing_prep.role = "assistant"
|
||||
existing_prep.content = prose
|
||||
existing_prep.msg_metadata = new_metadata
|
||||
await session.commit()
|
||||
return existing_prep
|
||||
|
||||
prep_msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role="assistant",
|
||||
content=prose,
|
||||
msg_metadata=new_metadata,
|
||||
)
|
||||
session.add(prep_msg)
|
||||
await session.commit()
|
||||
return prep_msg
|
||||
|
||||
|
||||
# Backwards-compat alias — older imports may use the old name.
|
||||
generate_daily_prep = gather_daily_sections
|
||||
@@ -0,0 +1,140 @@
|
||||
"""APScheduler instance for the Journal — daily prep generation.
|
||||
|
||||
One per-user cron job: generate today's daily prep at the configured
|
||||
prep_time in the user's local timezone. Replaces briefing_scheduler.
|
||||
|
||||
Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by
|
||||
event_scheduler.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import User, async_session
|
||||
from fabledassistant.services.journal_prep import ensure_daily_prep_message
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"prep_enabled": True,
|
||||
"prep_hour": 5,
|
||||
"prep_minute": 0,
|
||||
"day_rollover_hour": 4,
|
||||
"morning_end_hour": 12,
|
||||
"midday_end_hour": 18,
|
||||
}
|
||||
|
||||
|
||||
async def get_journal_config(user_id: int) -> dict:
|
||||
"""Load a user's journal_config (JSON in settings) merged with defaults."""
|
||||
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; using defaults", user_id)
|
||||
return {**DEFAULT_CONFIG, **config}
|
||||
|
||||
|
||||
async def get_user_timezone(user_id: int) -> str:
|
||||
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
|
||||
|
||||
|
||||
def _resolve_tz(tz_str: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
async def _do_daily_prep(user_id: int) -> None:
|
||||
try:
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
config = await get_journal_config(user_id)
|
||||
rollover = int(config.get("day_rollover_hour", 4))
|
||||
now = datetime.datetime.now(tz)
|
||||
# Today is the day after the most recent rollover.
|
||||
if now.hour < rollover:
|
||||
today = (now - datetime.timedelta(days=1)).date()
|
||||
else:
|
||||
today = now.date()
|
||||
await ensure_daily_prep_message(
|
||||
user_id=user_id, day_date=today, user_timezone=tz_str
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Daily prep failed for user %d", user_id)
|
||||
|
||||
|
||||
def _run_daily_prep_threadsafe(user_id: int) -> None:
|
||||
if _loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop)
|
||||
|
||||
|
||||
async def update_user_schedule(user_id: int) -> None:
|
||||
"""Add or replace this user's daily-prep job using their current config."""
|
||||
if _scheduler is None:
|
||||
return
|
||||
job_id = f"journal_prep_{user_id}"
|
||||
if _scheduler.get_job(job_id):
|
||||
_scheduler.remove_job(job_id)
|
||||
|
||||
config = await get_journal_config(user_id)
|
||||
if not config.get("prep_enabled", True):
|
||||
return
|
||||
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
prep_hour = int(config.get("prep_hour", 5))
|
||||
prep_minute = int(config.get("prep_minute", 0))
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_daily_prep_threadsafe,
|
||||
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
|
||||
args=[user_id],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
|
||||
async def _register_all_user_jobs() -> None:
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
for user in users:
|
||||
await update_user_schedule(user.id)
|
||||
|
||||
|
||||
def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler()
|
||||
_scheduler.start()
|
||||
asyncio.run_coroutine_threadsafe(_register_all_user_jobs(), loop)
|
||||
logger.info("Journal scheduler started")
|
||||
|
||||
|
||||
def stop_journal_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Journal scheduler stopped")
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Search across Moments and (optionally) journal transcripts.
|
||||
|
||||
Three modes, expressed through one tool surface:
|
||||
1. Pure temporal: no query, just date range -> ordered by occurred_at DESC.
|
||||
2. Pure entity: person_id or place_id filter -> junction lookup.
|
||||
3. Semantic: query string -> embedding similarity, optionally constrained.
|
||||
|
||||
This module ONLY queries Moments and (optionally) journal-conversation
|
||||
messages. It MUST NOT touch notes or note_embeddings. The notes-RAG and
|
||||
journal-RAG isolation is a hard invariant of the journal design.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import math
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import (
|
||||
Conversation,
|
||||
Message,
|
||||
Moment,
|
||||
MomentEmbedding,
|
||||
async_session,
|
||||
moment_people,
|
||||
moment_places,
|
||||
)
|
||||
from fabledassistant.services.embeddings import get_embedding
|
||||
|
||||
DEFAULT_LIMIT = 10
|
||||
DEFAULT_THRESHOLD = 0.55
|
||||
|
||||
|
||||
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = math.sqrt(sum(x * x for x in a))
|
||||
norm_b = math.sqrt(sum(y * y for y in b))
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
async def search_journal(
|
||||
*,
|
||||
user_id: int,
|
||||
query: str | None = None,
|
||||
person_id: int | None = None,
|
||||
place_id: int | None = None,
|
||||
tag: str | None = None,
|
||||
date_from: datetime.date | None = None,
|
||||
date_to: datetime.date | None = None,
|
||||
include_transcripts: bool = False,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
threshold: float = DEFAULT_THRESHOLD,
|
||||
) -> list[dict]:
|
||||
"""Return Moments (and optional transcript snippets) matching the filters.
|
||||
|
||||
Result rows: dicts from Moment.to_dict() plus an optional `score` when
|
||||
`query` is set. Transcript snippets carry `kind='transcript'` and `id=None`
|
||||
to distinguish them.
|
||||
"""
|
||||
moment_rows = await _search_moments(
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
person_id=person_id,
|
||||
place_id=place_id,
|
||||
tag=tag,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
threshold=threshold,
|
||||
)
|
||||
|
||||
if not include_transcripts:
|
||||
return moment_rows
|
||||
|
||||
transcript_rows = await _search_transcripts(
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
)
|
||||
return moment_rows + transcript_rows
|
||||
|
||||
|
||||
async def _search_moments(
|
||||
*,
|
||||
user_id: int,
|
||||
query: str | None,
|
||||
person_id: int | None,
|
||||
place_id: int | None,
|
||||
tag: str | None,
|
||||
date_from: datetime.date | None,
|
||||
date_to: datetime.date | None,
|
||||
limit: int,
|
||||
threshold: float,
|
||||
) -> list[dict]:
|
||||
async with async_session() as session:
|
||||
stmt = select(Moment).where(Moment.user_id == user_id)
|
||||
|
||||
if person_id is not None:
|
||||
stmt = stmt.join(moment_people, moment_people.c.moment_id == Moment.id).where(
|
||||
moment_people.c.person_id == person_id
|
||||
)
|
||||
if place_id is not None:
|
||||
stmt = stmt.join(moment_places, moment_places.c.moment_id == Moment.id).where(
|
||||
moment_places.c.place_id == place_id
|
||||
)
|
||||
if tag is not None:
|
||||
stmt = stmt.where(Moment.tags.any(tag))
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Moment.day_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Moment.day_date <= date_to)
|
||||
|
||||
if query is None:
|
||||
stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit)
|
||||
moments = (await session.execute(stmt)).scalars().all()
|
||||
return [m.to_dict() for m in moments]
|
||||
|
||||
# Semantic mode. Pull a wider candidate set, then rank in Python.
|
||||
candidate_stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit * 5)
|
||||
candidates = (await session.execute(candidate_stmt)).scalars().all()
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
candidate_ids = [m.id for m in candidates]
|
||||
emb_stmt = select(MomentEmbedding).where(
|
||||
MomentEmbedding.moment_id.in_(candidate_ids)
|
||||
)
|
||||
embeddings = {
|
||||
e.moment_id: e.embedding
|
||||
for e in (await session.execute(emb_stmt)).scalars()
|
||||
}
|
||||
|
||||
query_vec = await get_embedding(query)
|
||||
scored = []
|
||||
for m in candidates:
|
||||
vec = embeddings.get(m.id)
|
||||
if vec is None:
|
||||
continue
|
||||
score = _cosine(query_vec, vec)
|
||||
if score >= threshold:
|
||||
row = m.to_dict()
|
||||
row["score"] = score
|
||||
scored.append(row)
|
||||
|
||||
scored.sort(key=lambda r: r["score"], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
|
||||
async def _search_transcripts(
|
||||
*,
|
||||
user_id: int,
|
||||
query: str | None,
|
||||
date_from: datetime.date | None,
|
||||
date_to: datetime.date | None,
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""Fallback substring search over raw journal Messages.
|
||||
|
||||
Substring is intentional: transcripts catch what the LLM didn't extract
|
||||
as Moments. Semantic search over messages would require a third embedding
|
||||
index, which we deliberately don't maintain.
|
||||
"""
|
||||
if query is None:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Message, Conversation.day_date)
|
||||
.join(Conversation, Message.conversation_id == Conversation.id)
|
||||
.where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Message.content.ilike(f"%{query}%"),
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Conversation.day_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Conversation.day_date <= date_to)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return [
|
||||
{
|
||||
"id": None,
|
||||
"kind": "transcript",
|
||||
"message_id": msg.id,
|
||||
"conversation_id": msg.conversation_id,
|
||||
"day_date": day_date.isoformat() if day_date else None,
|
||||
"occurred_at": msg.created_at.isoformat(),
|
||||
"content": msg.content[:400],
|
||||
"raw_excerpt": None,
|
||||
"tags": [],
|
||||
"people": [],
|
||||
"places": [],
|
||||
}
|
||||
for msg, day_date in rows
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user