Compare commits

...

143 Commits

Author SHA1 Message Date
bvandeusen 58e5c6bc60 Merge pull request 'Release v26.04.07.2' (#22) from dev into main 2026-04-07 12:39:52 +00:00
bvandeusen d3170e5545 fix(chat): fetch full article content in from-article endpoint 2026-04-07 08:13:40 -04:00
bvandeusen 814f44c3fb fix(push): fix VAPID key format and add regenerate endpoint + UI button 2026-04-07 06:59:06 -04:00
bvandeusen 1d0cf4828b fix(briefing): fetch full article content via trafilatura in discuss endpoint 2026-04-07 06:55:15 -04:00
bvandeusen 56f1c44b8e Release v26.04.07.1 2026-04-07 02:40:27 +00:00
bvandeusen a5e35f7c72 feat: mount VoiceOverlay and wire Space bar shortcut in App.vue 2026-04-06 20:20:19 -04:00
bvandeusen 853dc810ff feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay 2026-04-06 20:01:44 -04:00
bvandeusen ac7dde472f feat: expose live stream ref from useVoiceRecorder 2026-04-06 20:00:22 -04:00
bvandeusen 84926d4ba2 feat: add useSilenceDetector composable with Web Audio API amplitude monitoring 2026-04-06 19:48:32 -04:00
bvandeusen 9b69e38aff docs: add web voice overlay polish implementation plan 2026-04-06 19:40:56 -04:00
bvandeusen 1b68559bfe docs: add web voice overlay polish spec 2026-04-06 19:39:27 -04:00
bvandeusen edf0a9063e fix: prevent model from inferring project names on create_task/create_note
The model was hallucinating project names from task/note content (e.g.
inferring "Vehicle Maintenance" from "purchase wheel bearings"). Added
explicit guidance to both project field descriptions: only set if the
user explicitly named a project, never infer from content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:17:43 -04:00
bvandeusen f2c2117b25 Merge pull request 'feat: add News link to main navigation header' (#20) from dev into main 2026-04-06 22:20:27 +00:00
bvandeusen b9d0716b01 feat: add News link to main navigation header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:49:22 -04:00
bvandeusen 9af8ab8f70 fix(briefing): use briefing context for follow-ups; add slot separator
- build_context: when conversation_type is 'briefing', inject a system
  prompt instruction telling the model to answer from conversation history
  and article context instead of searching the web
- Consolidate briefing conversation type detection to one DB query (was
  being checked twice — once for the system prompt addition, once for
  article context injection)
- ChatPanel: render a visual 'New Briefing Update' separator line before
  2nd+ briefing slot messages (identified by metadata.rss_item_ids)
- types/chat.ts: add metadata field to Message interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 06:15:18 -04:00
bvandeusen a171210224 feat(tools): confirmed guard for deletes, update_person/place, get/update_profile, calculate
- delete_note / delete_task: add confirmed parameter + requires_confirmation guard
  (find the note first, then ask, consistent with create_note/task pattern)
- get_note: description now mentions notes AND tasks
- update_person / update_place: new tools to update existing entity notes in-place
- get_profile / update_profile: surface and edit the user's stored profile
  (expertise, tone, response style, job title, interests)
- calculate: eval math expressions via Python math module; solves precision issues
  on multi-step arithmetic and supports sqrt/log/trig/etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:58:14 -04:00
bvandeusen eb92b2a976 feat(research): multi-note pipeline — outline + parallel section synthesis + index note
Replaces the single monolithic research note with topic-driven section notes
plus an index note. Two new LLM calls: _generate_outline (JSON outline, 3-8
sections) and _synthesize_section (300-600 word focused note per section,
parallelised via asyncio.gather). Public signature of run_research_pipeline
unchanged; falls back to single-note synthesis on outline failure or if all
sections fail.

Also extracts _build_sources_block helper and adds full test suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:53:14 -04:00
bvandeusen be805073a7 docs: add research multi-note redesign spec 2026-04-05 22:42:51 -04:00
bvandeusen e4c812a603 feat(voice): improve TTS logging for root-cause diagnosis
- Route now logs every synthesis request (char count, voice, speed)
- Route logs char count + text preview when the 8000-char limit is hit
- Route logs empty audio with preview (helps spot no-chunk-produced edge case)
- Route logs success with byte count and duration
- Kokoro synthesise() logs per-call: samples produced, elapsed, chars/s
- Kokoro synthesise() logs warning when zero audio chunks returned with preview
- Kokoro synthesise() catches and logs pipeline-internal errors with preview
- Frontend: console.warn now includes char count + 80-char preview on failure and retry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:36:43 -04:00
bvandeusen 8f7590d322 Release v26.04.06.1 — Article reading, quick capture rewrite, settings consistency 2026-04-06 02:20:59 +00:00
bvandeusen 3bdadaeca8 fix(embeddings): remove stale CONTENT_MAX_CHARS import from rss.py
CONTENT_MAX_CHARS was removed from rss.py when the article content cap
was lifted. backfill_rss_article_content still referenced it, causing an
ImportError on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:04:10 -04:00
bvandeusen c8c0de3b04 feat(settings): SSO account guard + remove redundant Office Days
- Account tab: SSO users see an info banner instead of email/password forms
- Briefing tab: remove Office Days section (work days now come from Profile)
- Remove unused toggleWorkDay function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:41:26 -04:00
bvandeusen 1357046160 feat(settings): add timezone field to General tab
- New Timezone section with text input + Detect button
- Detect auto-fills from browser Intl API
- Save calls PUT /api/settings (which now propagates to scheduler)
- Briefing tab firing timezone hint reads stored value instead of live browser API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:39:08 -04:00
bvandeusen 8ec91ceea7 feat(settings): propagate user_timezone to briefing scheduler on save
When user_timezone is saved via PUT /api/settings, immediately call
update_user_schedule if briefing is enabled so the scheduler picks up
the new timezone without requiring a restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:35:00 -04:00
bvandeusen 0ec030cb8f feat(scheduler): slot gating + morning work-day gate
- _add_user_jobs now accepts config dict and skips disabled slots
- _get_briefing_enabled_users returns 3-tuple (user_id, tz, config)
- update_user_schedule passes config through to _add_user_jobs
- _run_slot_for_user skips morning slot on non-work days via profile.work_schedule
- Add tests for slot gating and work-day gate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:34:29 -04:00
bvandeusen f4aca40562 docs: add settings consistency pass design spec 2026-04-05 21:10:15 -04:00
bvandeusen 68eee57c9b refactor(quick-capture): replace intent router with native tool-calling
Removes the custom classify_capture_intent + _process_note two-pass
approach. The LLM now picks the right tool directly via Ollama's native
tool_calls API (same path as the main chat pipeline). _should_think
decides whether extended reasoning is needed based on input length/
complexity. intent.py deleted — no longer needed.

Android app and response format unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:49:44 -04:00
bvandeusen 284dcd1c63 feat(briefing): add article Discuss endpoint with synthetic tool exchange
POST /api/briefing/articles/<id>/discuss injects stored article content
as a persisted read_article tool exchange before triggering generation.
The LLM sees the article as already read; follow-ups retain context via
the fixed history builder. Frontend Discuss button now calls the new
endpoint instead of inlining article text in the user message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:36:45 -04:00
bvandeusen 7dc5af2e88 feat(chat): add tool_calls param to add_message for synthetic messages
Needed by the Discuss endpoint to persist synthetic read_article
tool exchanges before triggering generation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:33:54 -04:00
bvandeusen eeb671872a fix(chat): replay tool_calls in history so tool context survives follow-ups
The history builder was silently dropping tool_calls from prior turns,
causing the LLM to lose article/search context on every follow-up.
Now reconstructs the assistant tool_call dict + per-call tool result
entries. Messages without tool_calls are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:33:31 -04:00
bvandeusen 278927ec40 feat(tools): add read_article tool for fetching full article content
Adds read_article tool definition and execute_tool handler. Uses
_fetch_full_article (trafilatura) from rss.py, caps tool output at
40K chars to keep context window manageable. Always registered
(not gated on SearXNG). Tests cover success, failure, truncation,
empty URL, and history builder replay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:33:11 -04:00
bvandeusen f7d54a15c0 feat(rss): remove article content character cap
Trafilatura extracts only article body text (typically 2K–15K chars),
so storing the full content is safe without an artificial ceiling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:32:17 -04:00
bvandeusen 9dd4178774 docs: add article reading implementation plan 2026-04-05 15:32:54 -04:00
bvandeusen eed2f6c23a docs: add article reading design spec 2026-04-05 15:26:07 -04:00
bvandeusen db092b113e fix: rss classifier think-tag stripping, briefing calendar dict access, embed empty-string guard
- rss_classifier: strip <think>...</think> blocks (qwen3 reasoning output)
  before JSON parse; use strict=False for control chars; bump timeout 30s→120s
- briefing_pipeline: list_events returns dicts not Event objects — fix
  attribute access (.all_day/.start_dt/.title) to dict access
- embeddings: guard upsert_note_embedding and semantic_search_notes against
  empty-string input to prevent Ollama embed 400 errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:14:55 -04:00
bvandeusen b5106441dd fix(tests): add missing start_dt/end_dt to mock event to_dict in test_list_events 2026-04-05 00:05:27 -04:00
bvandeusen 94d21c4512 fix(settings): audit pass — model auto-pull on startup, background_model empty-string bug, base URL validation
- Startup now pulls Config.OLLAMA_MODEL (system default chat model) — previously only
  embedding and background models were pulled; the primary chat model was skipped
- _warm_user_models expanded to also pull user-configured default_model and
  background_model overrides that are missing from Ollama, rather than logging and
  skipping them; pulls run before warm/KV-cache priming
- Add background_model to _MODEL_KEYS in settings route so clearing the dropdown
  deletes the DB row instead of saving "", which caused Ollama failures in tag
  suggestions, title generation, project summaries, and RSS classification
- Add http/https scheme validation to PUT /api/admin/base-url matching the CalDAV
  route pattern; a bad value no longer silently breaks invite/password-reset links
- Update admin voice config description: "Reload models" button exists to avoid
  a server restart, so the old "restart required" text was misleading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:08:14 -04:00
bvandeusen d9bd16633f fix(tasks): audit pass — permission checks, tool gaps, hard reload
- PUT/PATCH/DELETE /api/tasks/:id now use get_note_for_user + can_write_note
  so shared project editors can mutate tasks; owners unaffected
- PATCH /api/tasks/:id/status gets same treatment
- All write routes call update_note/delete_note with note.user_id (owner)
  not the accessing user's uid, matching the milestone fix pattern
- create_task tool gains tags (array) and status (enum) parameters;
  handler now passes tags to create_note and respects initial status
- create_task tool response now includes milestone_id and parent_id
- update_note tool gains milestone parameter; handler resolves the
  milestone by title within the note's current (or newly set) project,
  clears milestone_id when project is cleared
- list_tasks tool gains q keyword search parameter; passed through
  to list_notes
- TaskEditorView: replace window.location.reload() with
  router.push('/tasks/:id') after save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:48:07 -04:00
bvandeusen c5191837fb fix(projects): audit pass — 8 correctness and consistency fixes
- Project.to_dict() now includes user_id and auto_summary
- Status validation unified to (active/completed/archived) on both
  create and update project routes; update route previously had none
- Milestone routes: replace get_project (ownership-only) with
  get_project_for_user so shared viewers/editors can access milestones
- Add get_milestone_in_project() to milestones service for project-
  scoped lookup without user_id filter; all milestone routes use it
- Milestone PATCH now validates status as 'active'|'done'; fix tool
  enum which was wrongly ['active','completed','cancelled']
- Write mutation routes (POST/PATCH/DELETE milestones) now check
  can_write_project() and return 403 for read-only shared users
- update_project tool now exposes title and color fields so projects
  can be renamed or recolored via chat
- create_project tool now exposes color field
- GET /api/projects?include_summary=true embeds summaries in one
  backend pass; ProjectListView switches to this, eliminating N+1
  per-project fetches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:14:42 -04:00
bvandeusen ed715dcc23 feat(knowledge): note types, counts, new-note button, audit fixes
- Add note_type (note/person/place/list) selector + entity metadata fields
  (relationship, email, phone / address, hours) to NoteEditorView
- Pre-select type via ?type= query param from KnowledgeView new-note dropdown
- KnowledgeView: add split "New note / ▾" button with type dropdown
- KnowledgeView: show per-type counts on sidebar filter buttons (when > 1)
- Fix: filter-btn now flex layout so count badge aligns to right edge
- Fix: list_notes count_query was missing parent_id filter (inflated totals)
- Fix: PATCH /api/notes/:id now fires upsert_note_embedding (workspace autosave)
- Fix: get_knowledge_counts endpoint for per-type counts
- Fix: get_knowledge_tags was silently discarding note_type filter (double-stmt bug)
- Fix: NoteEditorView onMounted stray brace from edit session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:53:09 -04:00
bvandeusen 738245af5c fix(chat): audit fixes — retention, rag_project_id, cleanup scheduler, tool rounds
- cleanup_old_conversations now excludes briefing conversations (was
  silently deleting briefing history after the retention window)
- list_conversations response now includes rag_project_id, matching the
  shape returned by the single-conversation GET endpoint
- create_conversation_from_article: removed duplicate async_session import
  (_session2 was a copy of the same import); consolidated into one
- MAX_TOOL_ROUNDS fixed from 5→6 to match the actual range(6) loop;
  loop updated to range(MAX_TOOL_ROUNDS) so the constant is accurate
- Chat retention cleanup moved from per-request (every GET /conversations)
  to a daily scheduled job in event_scheduler.py; route no longer runs
  a DB write on every read

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:30:41 -04:00
bvandeusen edfed6b5bb feat(events): recurring expansion, CalDAV pull sync, past search, reminders
- RRULE expansion: list_events now expands recurring events into
  individual occurrences within the query window using python-dateutil
- CalDAV pull sync: new caldav_sync.py + POST /api/events/sync route;
  imports remote events into the internal store by caldav_uid
- Past event search: search_events accepts include_past=true to search
  historical events; exposed in the LLM tool definition
- Internal reminders: migration 0037 adds reminder_minutes +
  reminder_sent_at columns; event_scheduler.py checks every 5 min and
  fires push notifications; CalDAV sync job runs hourly
- reminder_minutes now stored and returned in create/update routes + tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:15:37 -04:00
bvandeusen 358534efbf fix(events): audit pass — 7 correctness fixes across the events system
Backend:
- tools.py: apply UTC normalization to update_event datetime fields
  (matched create_event which already did this)
- events.py service: allow end_dt/recurrence/project_id to be cleared
  via update_event by permitting None for nullable fields
- events.py service: find_events_by_query now returns upcoming events
  first, falling back to past — prevents AI tools from mutating stale
  past events when a future match exists
- events.py service: list_events now uses overlap logic (start <= to
  AND end >= from) so multi-day events spanning the query boundary
  are included; previously only start_dt was checked

Frontend:
- ToolCallCard: fire fable:calendar-changed on created/updated/deleted
  so CalendarView refetches without requiring a manual page refresh
- KnowledgeView: replace raw apiGet('/api/events') with listEvents()
  client function; also fix today bar which was reading .events off a
  flat array (always empty) — now correctly receives EventEntry[]
- HomeView: use full ISO strings for event date range instead of naive
  UTC-midnight strings; deduplicate inline date math via _dateRange()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:02:10 -04:00
bvandeusen 7677ab4028 fix(calendar): use date-only strings for all-day events to prevent timezone shift
UTC midnight passed to FullCalendar's timeZone:'local' was being
converted to local time, shifting all-day events back by 1+ days for
users in UTC-X zones. The edit form had the same bug via new Date().

Fix: pass YYYY-MM-DD slices (UTC date) for all-day events in both
toFcEvent and EventSlideOver resetForm, bypassing timezone conversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:01:51 -04:00
bvandeusen 90afbec4c2 feat(knowledge): two-tier pagination — ID pre-fetch + content batch loading
Backend:
- GET /api/knowledge/ids: returns up to 100 note IDs cheaply (no body
  parsing), supports same filters as /api/knowledge, includes has_more
- GET /api/knowledge/batch?ids=...: fetches full items for given IDs in
  order; used by frontend to load content in controlled batches

Frontend (KnowledgeView):
- Fetch 100 IDs upfront, load first 50 as content on mount
- IntersectionObserver sentinel (root: null) triggers 24-item content
  batches as user scrolls
- Proactive ID refill when queue drops below 48 unloaded IDs
- fetchGen counter invalidates stale in-flight responses on filter reset
- IDs claimed before async fetch to prevent double-loading
- sentinelVisible ref drives post-load re-check when content doesn't
  push sentinel off screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:44:50 -04:00
bvandeusen 5495fd1500 fix(knowledge): debounce scroll handler with rAF to prevent duplicate page loads
Coalesces rapid scroll events into one check per animation frame so
that page++ can only fire once per frame, eliminating the window where
multiple events slip through before loading=true is observed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:25:52 -04:00
bvandeusen 1fc0004e93 fix(knowledge): replace IntersectionObserver with scroll event; increase card height
- Swap IntersectionObserver (race-prone, fired immediately on creation)
  for a passive scroll listener on the grid container — eliminates
  duplicate page loads caused by observer re-creation after DOM updates
- Increase card min-height 100px → 160px so tags + snippet are visible
- Increase snippet line-clamp 3 → 4 for more content preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:24:19 -04:00
bvandeusen 968e536d3a fix(events): normalize naive datetimes to UTC in HTTP route
All datetime parsing now uses _parse_dt() which adds UTC tzinfo when
none is present, matching the fix already applied in tools.py. This
prevents asyncpg errors when comparing naive datetimes against
TIMESTAMPTZ columns — the root cause of events not appearing in the
calendar view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:00:43 -04:00
bvandeusen 3c38c04ad4 fix(knowledge): sentinel DOM ordering + restore tag visibility
Sentinel was first in DOM with order:9999, causing layout recalculation to
trigger IntersectionObserver multiple times (intersecting→not→intersecting)
as items were appended, producing duplicate pages. Move sentinel to AFTER
the v-for items so it's naturally last in both DOM and visual order.

Remove overflow:hidden from .k-card-tags — .k-card overflow:hidden already
clips escaping content; the extra overflow on the tags container was
collapsing its height to zero and hiding all tag pills.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:54:35 -04:00
bvandeusen 4ac26d9326 fix(knowledge): clip card overflow so tags don't escape card boundary
Added overflow:hidden to .k-card so wrapped tags are clipped within the
card border-radius. Added min-width:0 + overflow:hidden on .k-card-tags
so the flex item can shrink properly and doesn't push past the card width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 21:57:28 -04:00
bvandeusen 00abfcf4db fix(knowledge): remove backup load check causing duplicate items on scroll
The IntersectionObserver fires as soon as it's created (sentinel immediately
intersecting after page 1 renders), while the removed backup check also fires
in the same tick — two concurrent fetchItems(page 2) calls produced duplicate
cards. With sentinel now properly inside the scrolling root, the observer
alone handles progressive loading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 21:55:11 -04:00
bvandeusen 3887cab66e fix: knowledge infinite scroll + list_events timezone handling
KnowledgeView: sentinel was OUTSIDE the card-grid div, making
IntersectionObserver (root: cardGridEl) never fire since the target must
be a descendant of the root. Moved sentinel inside card-grid with
grid-column:1/-1 + order:9999 so it spans all columns and sits at the
bottom. Fixed backup check to compare against container bounds not viewport.

tools.py list_events: apply same UTC normalization as create_event (treat
naive datetimes as UTC, handle Z suffix). Update tool description to
explicitly request full-day UTC ranges so the LLM doesn't send local time
without offsets, which caused the recall query to miss UTC-stored events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 21:10:43 -04:00
bvandeusen aeb778f35a feat(calendar): month/year picker popover on title click
Click the month name in the FullCalendar toolbar to open a popover with
prev/next year arrows and a 4×3 month grid. Clicking a month jumps the
calendar to that month via gotoDate(). Current month highlighted. Picker
closes on outside click. Title gains hover highlight + pointer cursor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:27:01 -04:00
bvandeusen eda9c5ce43 feat: migrate KnowledgeView mini-chat to ChatPanel + enable note picker everywhere
- KnowledgeView: replace custom mini-chat (voice, PTT, manual scroll, message
  rendering) with ChatPanel variant="full"; gains RAG scope chip, TTS listen
  mode, volume control, and note picker automatically (-218 lines)
- ChatInputBar: remove briefingMode guard on note picker so attach/search works
  in briefing, workspace, widget, and knowledge chat surfaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:16:36 -04:00
bvandeusen d624d38412 fix: KnowledgeView infinite scroll root + calendar event refresh
- KnowledgeView: IntersectionObserver was watching the viewport instead
  of the card-grid scroll container, causing infinite scroll to stop
  loading after only ~29 items. Pass card-grid element as `root`.
- CalendarView: listen for 'fable:calendar-changed' custom event and
  call refetchEvents() so tool-created events appear without navigation.
- ChatPanel: dispatch 'fable:calendar-changed' when create_event,
  update_event, or delete_event tool calls succeed.
- tools.py: normalize naive datetimes to UTC before storing events so
  timezone comparisons in list_events queries are always consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 19:28:33 -04:00
bvandeusen 86e718dda1 refactor: migrate HomeView to ChatPanel widget, delete DashboardChatInput
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:36:13 -04:00
bvandeusen 0b1ed2afe5 refactor: migrate WorkspaceView chat to use ChatPanel component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 17:47:17 -04:00
bvandeusen 2c446be83a refactor: migrate BriefingView chat to use ChatPanel component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:50:18 -04:00
bvandeusen f36398f892 refactor: migrate ChatView to use ChatPanel component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:41:22 -04:00
bvandeusen 927f137aaf feat: rewrite ChatPanel with full and widget variants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:37:20 -04:00
bvandeusen 7eaf4d9dca feat: add ChatStreamingBubble extracted streaming state component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:30:05 -04:00
bvandeusen 89a9088b94 feat: add ChatInputBar unified input bar component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:57:51 -04:00
bvandeusen c89586dcd5 docs: add ChatPanel unification implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:42:01 -04:00
bvandeusen fb26507123 docs: add ChatPanel unification design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:24:30 -04:00
bvandeusen 0a913045a8 fix(tts): play() must return a promise that resolves when audio finishes
Without this, await audio.play() resolves immediately after source.start(),
so the playQueue chains the next sentence before the current one finishes,
causing overlapping / interrupted playback.
2026-04-03 14:21:44 -04:00
bvandeusen c81a499e6e fix(tts): add speak() for complete responses; fix briefing message commit race 2026-04-03 14:18:34 -04:00
bvandeusen 8024706870 fix(knowledge): fill viewport on load when sentinel stays visible 2026-04-03 14:07:52 -04:00
bvandeusen 22003788f5 fix(generation): compute num_ctx in run_assist_generation 2026-04-03 13:35:47 -04:00
bvandeusen 9d519054ee feat(tts): add streaming TTS listen mode to WorkspaceView 2026-04-03 13:15:28 -04:00
bvandeusen b4f5a935b2 feat(tts): wire useStreamingTts into BriefingView 2026-04-03 13:11:12 -04:00
bvandeusen fda6a7acc1 feat(tts): wire useStreamingTts into ChatView 2026-04-03 13:09:52 -04:00
bvandeusen 7ffd412603 feat(tts): add useStreamingTts composable for sentence-level streaming 2026-04-03 12:54:05 -04:00
bvandeusen b92c6b1487 docs: streaming TTS implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 12:43:28 -04:00
bvandeusen b8cd2e5ed7 docs: streaming TTS design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 12:16:56 -04:00
bvandeusen ef55bcb560 feat(llm): adaptive num_ctx tiers + fix KV cache priming num_ctx mismatch
Adds pick_num_ctx() which selects the smallest context window tier
(8192, 16384, 32768) that fits the current messages with 25% headroom,
capped at OLLAMA_NUM_CTX. Threads num_ctx through generation_task.py so
every chat request uses the computed tier rather than a fixed 16384.

Fixes a critical cache miss bug: KV cache priming in app.py and
settings.py was sending requests without num_ctx, so Ollama sized the
cache at its model default (different from the 16384 real requests used),
forcing a full model reload on the first real user message. Both priming
sites now call pick_num_ctx() and pass the matching value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:47:39 -04:00
bvandeusen a6888953dc perf(prompts): trim tool schema descriptions to reduce prompt token count
Removes verbose redundant text from tool descriptions and system prompt
guidance: multi-line recurrence_rule JSON examples, CAPS warnings that
duplicate system prompt instructions, and wordy descriptions that don't
add model understanding.

Saves ~990 tokens per request (~17% reduction, 5,639 → ~4,650 tokens),
reducing prefill time on cache misses and lowering KV memory pressure.
No functional changes — parameter names, types, enums, and required
fields are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:01:05 -04:00
bvandeusen c9065c4481 perf(settings): prime KV cache when user changes chat model
When a user saves a new default_model in Settings, fire a background
cache-prime request so the first message with the new model is fast
rather than paying the full cold-start prefill cost.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:51:17 -04:00
bvandeusen 4792e6459b perf(startup): prime Ollama KV cache with system prompt on warm-up
After loading each user's chat model into VRAM, send a minimal chat
request with the real system prompt (num_predict=1) to populate the
KV cache. The first real user message then only needs to process its
own tokens rather than the full ~5,600-token system prompt, reducing
cold-start TTFT from ~25s to <1s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:47:59 -04:00
bvandeusen 3bd0dc6879 feat(settings): add background model picker with KV cache performance warning
Exposes OLLAMA_BACKGROUND_MODEL as a per-user setting in General settings,
alongside the Chat Model selector. Includes an inline warning when the same
model is selected for both, explaining the KV cache performance impact.

All background task callers (title generation, tag suggestions, project
summaries, RSS classification) now read background_model from user settings,
falling back to OLLAMA_BACKGROUND_MODEL env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:44:08 -04:00
bvandeusen fa38978745 fix(lint): remove unused model variable and get_setting import in chat.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:17:35 -04:00
bvandeusen 750a91898a perf(llm): route background tasks to dedicated model to preserve KV cache
Background tasks (title generation, tag suggestions, project summaries,
RSS classification) were using qwen3:8b and wiping its KV cache after
every response, preventing prefix cache hits on subsequent user messages.

Adds OLLAMA_BACKGROUND_MODEL (default: qwen2.5:0.5b) config var and
routes all background LLM calls to it, keeping qwen3:8b's KV cache
warm between user messages for consistent sub-second TTFT.

Also adds infinite scroll to KnowledgeView (replaces load-more button)
and bakes spaCy en_core_web_sm into the Docker image to eliminate the
pip install on every startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 01:33:54 -04:00
bvandeusen 888b736ecd feat(weather): default to today only, add days parameter for multi-day requests
get_weather now returns 1 day by default (today) instead of a full 7-day
forecast. A new optional `days` parameter (1–8) lets the model request
more days when the user explicitly asks for a weekly forecast or specific
date range. Tool description updated to guide the model accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:55:40 -04:00
bvandeusen a473f6e039 fix: minichat markdown rendering and weather temperature unit preference
- KnowledgeView minichat: render assistant messages through renderMarkdown
  so headers, bold, lists etc. display correctly instead of raw markdown
- get_weather tool: read user's temp_unit from briefing_config and convert
  temperatures to °F when preferred; also include temp_unit in the
  returned payload so the model can label values correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:52:54 -04:00
bvandeusen 07f4956550 fix: center minichat widget and fix weather tool success status
- KnowledgeView minichat: add margin-inline: auto so the widget centers
  within the content area when max-width is reached on wide screens
- weather get_weather tool: return success: true on both the arbitrary
  location path and the cached locations path so ToolCallCard shows
  the correct success state instead of always flagging as error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:50:42 -04:00
bvandeusen b4be1f0799 perf(llm): move retrieval context to user turn for stable system prompt
RAG notes, RSS news, current note, URL content, and briefing articles
are now prepended to the user message rather than appended to the system
message. The system message now contains only stable content (persona,
tool guidance, date, profile, workspace, history summary), making its
token sequence identical across consecutive requests and allowing
Ollama's KV prefix cache to fire reliably every time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:44:17 -04:00
bvandeusen 36634919cc fix(home): center quick chat widget and constrain width
Chat section and inline response now have max-width 720px centered
within the page, and quick action chips are centered. Prevents the
widget from stretching the full 1200px content width on wide screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:26:53 -04:00
bvandeusen 8a10eb9dbd feat(generation): add conditional thinking classifier
Routes simple/conversational messages to think=false automatically,
even when the user has thinking enabled. Patterns checked: word count
thresholds, complexity keywords, code blocks, skip patterns for greetings
and simple CRUD. Workspace mode (think=true from frontend) still benefits
from the classifier on short messages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:19:02 -04:00
bvandeusen 2422946b4f perf: remove model-load polling before generation
wait_for_model_loaded() polled /api/ps for up to 180s waiting for the
model to appear as loaded. But Ollama lazy-loads models on the first
/api/chat request, so the poll will never succeed — it just blocks for
the full 180s after every Ollama restart before proceeding.

Removed the wait entirely. Ollama handles on-demand loading correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 22:36:31 -04:00
bvandeusen b416fec292 perf: reduce OLLAMA_NUM_CTX default from 65536 to 16384
65536 was causing Ollama to allocate a ~50GB KV cache, spilling 77% of
the model to CPU RAM and making prefill extremely slow (35-125s TTFT).

16384 covers 30+ message conversations comfortably while keeping the KV
cache small enough to stay on GPU.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:49:55 -04:00
bvandeusen d20320b664 fix(fable-mcp): raise read timeout to 300s for cold model load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:45:51 -04:00
bvandeusen c3665ddda5 perf: restructure system prompt for Ollama KV-cache prefix reuse
Move static content (persona + tool guidance) to a fixed prefix and
append all dynamic content (date, timezone, profile, entities) as a tail.

Ollama prefix caching requires byte-for-byte token match from the start
of the prompt. Previously, Today's date + user profile were embedded
mid-prompt, invalidating the cache on every request/day and causing
~20s TTFT regardless of model warmth.

With this change the static prefix (~5500 tokens) should be cached
after the first request each session, reducing TTFT to ~2-5s for the
~200-token dynamic tail only.

Also removed inline user_timezone from tool_lines (timezone is now
stated once in the dynamic tail, which the model reads).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:29:26 -04:00
bvandeusen a7160772bf fix(fable-mcp): fix SSE parser and add stream retry for race condition
- Parse multi-line SSE format correctly (event: on separate line, chunk in data.chunk)
- Retry stream GET up to 10x with 300ms backoff when 404 (buffer not ready yet)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:10:12 -04:00
bvandeusen 746b21fa4c fix(fable-mcp): fix send_message to use correct two-step API flow
POST .../messages to start generation, then stream from .../generation/stream.
The previous implementation used a non-existent /stream endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:02:44 -04:00
bvandeusen 8a54daf3c9 fix(fable-mcp): fix streaming error handling and bump timeout for SSE
- stream_get now reads error responses before calling _raise_for_status
  (httpx raises on .json()/.text access inside an unread stream context)
- Raise read timeout to 120s (was 30s) so generation streams don't timeout
- Document "generation" as a valid category for fable_get_app_logs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:46:39 -04:00
bvandeusen 25d448f896 feat: add generation metrics to logs (think, rounds, tokens)
Log think flag, round count, prompt/output token counts per generation.
Change log category from 'usage' to 'generation' for clean MCP filtering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:14:41 -04:00
bvandeusen 3e42992f67 fix: stop CI from filling runner disk
Three sources of unbounded growth removed:
- Drop cache-from/cache-to registry: on a persistent self-hosted runner the
  local BuildKit layer cache already provides between-run reuse; the registry
  cache was redundant and pushed ~2 GB of torch layers on every build
- Switch docker system prune -f → -af so old :SHA-tagged images are removed,
  not just dangling ones (-f alone never touched named tags)
- Add docker builder prune --keep-storage 5g to bound the local BuildKit
  cache; pip mount cache (torch etc.) is recently-used so survives, stale
  intermediate layers are evicted first

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:44:09 -04:00
bvandeusen 749a60b9fd feat: interactive checkboxes in list note viewer
- renderMarkdown() accepts interactiveCheckboxes option: removes disabled=""
  and stamps data-task-index on each checkbox in the marked HTML output
- NoteViewerView detects list notes by body content (- [ ] / - [x] pattern)
  and passes interactiveCheckboxes: true when rendering
- onBodyChange() handles checkbox change events: toggles the matching line
  in the body, optimistically updates the store, then PATCHes the note
- prose.css adds .prose--checklist rules for marked output: no bullet,
  flex row, accent-color, line-through on checked items via :has()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:31:37 -04:00
bvandeusen aec7a910f0 fix: disable provenance attestation for Forgejo registry compatibility
build-push-action@v7 generates OCI attestation manifests by default.
Forgejo's registry doesn't support OCI image index format with attestations,
causing the push to fail with "unknown". provenance: false disables this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:11:29 -04:00
bvandeusen 9549eb85bc feat: live card refresh, list checkboxes, minichat width cap
KnowledgeView:
- Watch streamingToolCalls; call fetchItems+fetchTags on create/update
  note or task so the card grid reflects changes made via the minichat
- Cap minichat to max-width: var(--page-max-width) so it matches chat column width

WorkspaceView + WorkspaceNoteEditor:
- Expose reload() from WorkspaceNoteEditor via defineExpose
- Call noteEditorRef.reload() alongside taskPanelRef.reload() when
  create_note/update_note tools succeed in the SSE watcher

KnowledgeView list cards:
- Backend: parse markdown task list into list_items [{text, checked}] + body
- Card renders up to 6 items with real checkboxes; toggleListItem()
  does an optimistic update then PATCHes /api/notes/:id
- Progress bar kept below items; "+N more" shown when list is long

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:30:28 -04:00
bvandeusen 23a7ed7822 refactor: DRY layout bounds via --page-max-width and --sidebar-width CSS vars
Add --page-max-width (1200px), --page-padding-x (1rem), and --sidebar-width (260px)
to theme.css so all views share a single source of truth.

- HomeView: 1100px → var(--page-max-width) (aligns with all other views)
- NotesListView, TasksListView, ProjectListView, ProjectView, CalendarView: var(--page-max-width)
- ChatView: sidebar + context sidebar → var(--sidebar-width); inner message/input
  column max-widths → var(--page-max-width)
- KnowledgeView: filter panel 180px → var(--sidebar-width); minichat left offset updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:56:33 -04:00
bvandeusen d5771a3d5c fix: remove view max-width constraints; widen graph panel
- Drop max-width from .knowledge-root so the graph panel can use the full
  viewport width without hitting a cap
- Drop max-width from .chat-page (message bubbles are already self-constraining)
- Increase normal graph panel width 420px → 500px
- Increase expanded width to min(960px, 60vw) so it scales with the viewport
  and updates minichat right-offset to match

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:39:21 -04:00
bvandeusen c95afa4558 fix: install CPU-only torch to avoid 2GB+ CUDA packages on CI runner
kokoro and transformers pull full nvidia CUDA wheels by default (~2 GB),
exhausting the runner disk. Pre-installing torch from the CPU wheel index
satisfies the dependency and prevents pip from selecting the CUDA variant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:02:09 -04:00
bvandeusen 8140bc022c fix: resolve ENOSPC on CI by using BuildKit pip cache and registry layer cache
- Add `# syntax=docker/dockerfile:1` to enable BuildKit cache mounts
- Replace `--no-cache-dir` pip installs with `--mount=type=cache,target=/root/.cache/pip`
  so torch/CUDA wheels are reused across builds instead of re-downloaded every run
- Add `docker system prune -f` step before build to free dangling image/layer space
- Add `cache-from`/`cache-to` pointing to `:cache` tag so unchanged layers
  (including the heavy voice-deps layer) are pulled from registry instead of rebuilt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:23:59 -04:00
bvandeusen 0d12d01115 feat: constrain Chat/Knowledge width + persistent expandable graph panel
Layout:
- Chat and Knowledge views now max out at 1600px and center on wide
  screens, consistent with the rest of the app; app-content overflow
  is set to hidden for both so they manage their own scroll

Graph panel (Knowledge):
- Open/closed state persisted to localStorage (fa_knowledge_graph_open)
  — stays open across navigation and page refreshes
- Expanded state persisted (fa_knowledge_graph_expanded): chevron button
  in the panel header toggles between 420px (normal) and 700px (expanded)
- Minichat right offset follows the panel width with a matching transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:01:18 -04:00
bvandeusen d96895c276 fix: briefing refresh — weather not populated + workspace conv bleed
Two bugs:

1. Manual 'Refresh' button didn't refresh weather/RSS before compiling.
   The daily scheduler calls refresh_all_feeds + refresh_location_cache
   before run_compilation; the manual trigger route called run_compilation
   directly, reading a stale cache. Manual trigger now mirrors the
   scheduler: refresh feeds and all configured weather locations first.

2. Navigating to WorkspaceView during a long briefing compilation caused
   the workspace chat to show the briefing content. triggerNow awaits
   ~30-60s; on completion it called loadAll() → chatStore.fetchConversation
   which overwrote the workspace's currentConversation in the shared store.
   Fixed with a _mounted flag — all post-async state writes in BriefingView
   are now guarded so they no-op if the component has been unmounted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:46:53 -04:00
bvandeusen 145c18d8a3 refactor: DRY calendar additions + define --color-surface in theme
- Define --color-surface in theme.css (light: #f0f0f8, dark: #1a1b22)
  — was used across 10+ components with no-op fallbacks; now properly
  defined alongside --color-bg and --color-bg-card
- Extract shared date formatters into utils/dateFormat.ts:
  fmtTime, fmtDateTime, fmtRelativeDateTime, fmtDayLabel, fmtCompact
- Replace duplicate inline formatters in CalendarView, HomeView
  (formatUpcomingTime), and KnowledgeView (formatEventDate)
- CalendarView: replace hardcoded rgba(99,102,241,0.4) hover colour
  with color-mix(in srgb, var(--color-primary) 40%, transparent);
  fix --color-input-bg fallback to use var(--color-bg); remove
  hardcoded hex fallbacks now that --color-surface is defined

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:27:23 -04:00
bvandeusen f96013a4bc feat: calendar event popover + upcoming events strip
B — Event popover: clicking a calendar event shows a compact overlay with
    full details (title, time range, location, description, color accent)
    and Edit/Close actions; positioned relative to the click, closes on
    outside click; Edit opens the existing EventSlideOver

C — Upcoming strip: scrollable section below the calendar showing the
    next 4 weeks of events grouped by day (Today/Tomorrow/date label),
    each card with color accent bar, title, time, location, description
    snippet; clicking a card opens EventSlideOver for edit

Both features stay in sync with create/update/delete operations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:10:16 -04:00
bvandeusen 30981a3121 feat: persist listen mode across views via localStorage
Extract listen mode into a shared useListenMode() composable backed by
localStorage ('fa_listen_mode'). ChatView and BriefingView both use it,
so toggling auto-read on in one view keeps it on after navigation or
page refresh — no need to re-enable it each visit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:53:03 -04:00
bvandeusen 71b8c5965c fix: briefing discuss button — full article content + scroll + optimistic UI
Three bugs in discussArticle():
- Scroll selector was '.briefing-chat' (doesn't exist) → '.briefing-center';
  the panel never scrolled into view so the response was invisible until refresh
- Only 300-char snippet was sent to the LLM; now passes the full stored
  content (up to 2000 chars) from the backend
- User message wasn't shown until streaming ended; now added optimistically
  to messages[] immediately on click so it appears straight away

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:46:23 -04:00
bvandeusen 5924e565b1 fix: floating mini-chat overlay style + weather precip fallback
- KnowledgeView mini-chat: replace harsh border-top with upward box-shadow
  and rounded top corners (16px); remove padding-bottom from content area
  so widget truly overlays cards without pushing layout; add collapse
  toggle (chevron) that hides messages without closing the conversation
- WeatherCard: show precip_mm as fallback when precipitation_probability_max
  is null but actual rainfall is expected (Open-Meteo omits probability for
  some forecast days even when rain is shown)
- Pass precip_mm through weather service → frontend type definitions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:04:47 -04:00
bvandeusen a0620c4949 fix: knowledge view — chat input styling, autofocus, graph component (no iframe)
- Mini-chat input bar now uses shared --color-input-bar-* CSS variables and
  the same chat-input-bar pill pattern as all other chat interfaces
- chatInputEl focused on mount (autofocus on page load)
- Graph panel: replaced iframe (blocked by X-Frame-Options: DENY) with
  inline GraphView component; CSS :deep override constrains its height
  to fill the panel instead of 100vh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:49:02 -04:00
bvandeusen 95056d5be7 fix: rename Note.metadata → entity_meta (reserved by SQLAlchemy Declarative API)
SQLAlchemy reserves 'metadata' as a class attribute on declarative models.
Renamed to 'entity_meta' with explicit column name 'metadata' so the DB
column is unchanged but the Python attribute no longer conflicts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:13:47 -04:00
bvandeusen 80f30b705d feat: Knowledge view + entity types (People, Places, Lists)
Data model:
- Migration 0036: adds note_type TEXT (default 'note') and metadata JSONB
  to the notes table; index on note_type
- Note model: entity_type property, note_type/metadata in to_dict()
- create_note() accepts note_type and metadata params

Backend:
- /api/knowledge — unified paginated endpoint: type/tag/sort/q filters,
  semantic search via embeddings, excludes tasks
- /api/knowledge/tags — distinct tags across knowledge objects
- New LLM tools: create_person, create_place, create_list, add_to_list,
  clear_checked_items — all wired into execute_tool()
- People and places auto-injected as compact summary into LLM system prompt

Frontend:
- KnowledgeView replaces HomeView at /; left filter panel (type+tag),
  toolbar (search, sort, graph toggle), card grid with type-aware cards
  (indigo=note, emerald=person, amber=place, sky=list), load-more pagination
- Today bar: upcoming events, overdue task count, Briefing/Chat links
- Floating mini-chat sticky to bottom: creates/continues a conversation
  inline, message history expands upward, close button ends session
- Graph panel: toggles as a 420px right panel at full viewport width
- AppHeader: Knowledge, Chat, Briefing, Calendar, Tasks, Projects
- Router: / → KnowledgeView; /knowledge redirect; HomeView import removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:01:03 -04:00
bvandeusen 425d307180 feat: automatic Kokoro offline mode + daily update check
- On first load: model runs online (downloads .pt files), then stores the
  current HF commit SHA to /data/kokoro_commit_hash.txt and switches the
  process to offline mode (HF_HUB_OFFLINE) for all future requests
- On subsequent restarts: presence of the commit file triggers offline mode
  before the pipeline loads, skipping all HuggingFace network validation
- Daily at 03:00 UTC: scheduler temporarily lifts offline mode, fetches the
  latest commit SHA from HF, and only reloads the pipeline if the model has
  actually changed — then restores offline mode
- Removed HF_HUB_OFFLINE from docker-compose.yml; behaviour is now automatic
  and not a hoster/user concern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:06:13 -04:00
bvandeusen f2dd25737a feat: add HF_HUB_OFFLINE env var to skip HuggingFace cache validation on startup
Once Kokoro voice .pt files are cached locally, setting HF_HUB_OFFLINE=1
prevents HEAD requests to HuggingFace on each restart, making voice pre-warming
fully offline and faster.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:00:50 -04:00
bvandeusen 882ea176b2 fix: briefing discuss prompt — suppress research tool to prevent full research note
Explicitly instruct the LLM to respond conversationally and not use any
research/search tools when summarizing a shared article excerpt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:54:18 -04:00
bvandeusen baeb0b14e5 feat: listen mode + volume knob in chat; briefing discuss auto-send; fix LLM proactive note search
- ChatView: listen mode toggle (auto-reads new responses via TTS), volume popup
  with range slider persisted per-device in localStorage via GainNode
- useVoiceAudio: shared module-level _volume ref with localStorage persistence,
  GainNode for volume control, exported setVoiceVolume()
- tts.py: pre-warm all Kokoro voices at pipeline load to eliminate HuggingFace
  HEAD requests at synthesis time (reduces TTS latency)
- BriefingView: discuss article button now auto-sends instead of just filling input;
  prompt capped to 15 sentences; send() accepts optional overrideText
- llm.py: instruct LLM not to proactively search notes or comment on note absence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:52:11 -04:00
bvandeusen ab397e78f3 fix: briefing TTS now uses saved voice/speed/blend settings
synthesiseSpeech() called without explicit params now omits voice/speed/
blend from the request body. The backend detects this and auto-loads all
three from the user's saved settings (voice_tts_voice, voice_tts_speed,
voice_tts_blend), so briefing listen mode respects the voice the user
configured in Settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 13:46:52 -04:00
bvandeusen ea23f16bd7 feat: weather card — precip probability %, condition text, unit-aware wind
- Fetch precipitation_probability_max from Open-Meteo (replaces precip_sum
  in the card display — probability is more useful at a glance than mm)
- Show WMO condition description text on each forecast day
- Convert wind speed to mph when temp unit is F; pass wind_unit in response
- Display 💧 X% chance of rain; 💨 X mph/km/h wind per day

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 13:02:36 -04:00
bvandeusen c1fcb1e287 feat: discuss article in briefing chat via 💬 button on news cards
Clicking 💬 on a news card in the briefing panel pre-fills the briefing
chat input with the article title, snippet, and source so the user can
ask the briefing LLM to summarize or discuss it directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:57:25 -04:00
bvandeusen c31cf11767 fix: use AudioContext for voice previews to bypass browser autoplay policy
new Audio().play() after an async await loses the user gesture context and
is silently blocked. Creating AudioContext synchronously before the fetch
preserves the permission, then decode/play through it after the await.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:55:07 -04:00
bvandeusen 71ca0ecb5c fix: voice status global store, per-view mic reactivity, single-voice preview
- Move voice status into settings store (voiceSttReady, voiceTtsReady),
  checked once at login and refreshed after admin model reload
- ChatView, BriefingView, DashboardChatInput now use computed refs from
  the store — mic buttons appear reactively without needing a page reload
- BriefingView: separate STT-only guard for mic PTT vs TTS-only guard for
  listen mode / speak buttons
- Add ▶ Preview button to Voice & Speed section in Settings for single-
  voice testing without enabling blend mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:53:06 -04:00
bvandeusen b4b4b0d9d6 feat: weather precip/wind, dashboard mic, remove global voice overlay
- WeatherCard: show precipitation (mm) and max wind speed per forecast day
- DashboardChatInput: add PTT mic button (transcribe-to-input, voice-gated)
- Remove global VoiceOverlay floating button and Space PTT shortcut from
  App.vue — inline mic buttons in chat/briefing/dashboard are the right UX;
  global overlay had focus/latency/context issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:00:06 -04:00
bvandeusen 76c3dbc4b7 fix: stop TTS playback when PTT is activated
Pressing push-to-talk now immediately stops any ongoing TTS audio before
opening the microphone, preventing the assistant from hearing itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:43:15 -04:00
bvandeusen 1460863e82 feat: Kokoro voice blending — blend builder UI + weighted tensor synthesis
Add voice blend support to TTS pipeline and settings UI. Users can mix
2–5 Kokoro voices with per-voice weight sliders; the blended style tensor
replaces the single voice when enabled. Settings persist as JSON and auto-
load on synthesis when no explicit voice is supplied in the request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:10:22 -04:00
bvandeusen 98b3cdb593 feat: add weather condition icons to WeatherCard
Maps WMO condition strings to emoji icons for current conditions and
forecast days. No external dependencies — pure emoji lookup by condition text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:08:18 -04:00
bvandeusen c2d81e04b9 fix: update tests for briefing rewrite and raised content cap
- Remove test_slot_greeting — slot_greeting() was removed in the
  conversational briefing rewrite
- Update test_extract_item_truncates_content to use CONTENT_MAX_CHARS
  rather than a hardcoded 2000 (cap raised to 50000 for full articles)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:42:48 -04:00
bvandeusen e613485474 feat: full article fetching with trafilatura + html2text cleanup
- Add trafilatura + html2text to dependencies
- Replace custom HTMLStripper with html2text for RSS feed content
- Fetch full article text via httpx + trafilatura after each new item is stored;
  falls back to RSS-provided content if fetch/extraction fails
- Raise CONTENT_MAX_CHARS from 2000 to 50000 (TEXT column, no migration needed)
- Re-embed items with full article content once enrichment completes
- Startup backfill enriches existing items with short content (<1000 chars)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:33:27 -04:00
bvandeusen 0b05b03987 fix: strip HTML from RSS item content during ingestion
feedparser returns HTML in content/summary fields for many feeds.
Raw tags were being stored in the DB and passed to the LLM/embeddings.
Added a stdlib HTMLParser-based stripper in extract_item() — block elements
become newlines, script/style content is dropped, plain text passes through.
No new dependencies required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:15:49 -04:00
bvandeusen a773c11aa0 feat: RSS embeddings, semantic news in chat, article-to-chat, richer briefings
- Embed RSS items at fetch time (nomic-embed-text); backfill at startup
- Semantic news search injected into chat system prompt ("Recent News You've Seen")
  when items match query above 0.55 cosine threshold (independent of note RAG)
- "Discuss in chat" button on news cards — creates a seeded conversation with
  the article title + full content, navigates directly to the new chat
- Briefing compilation now passes 500-char article excerpts (not just headlines)
  to the LLM and uses 8192 num_ctx to accommodate the larger prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 15:12:38 -04:00
bvandeusen dba41879ed feat: structured user profile with LLM-learned preferences
Replaces the freeform briefing-profile note with a DB-backed user_profiles
table. Users can edit job/industry/expertise/response preferences/interests/
work schedule via a new Settings → Profile tab. The LLM appends nightly
observations; at 14+ entries they are auto-consolidated into a learned_summary.
Profile context is injected into both briefing and chat system prompts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:17:30 -04:00
bvandeusen 9f3b9e45c6 feat: rewrite briefing pipeline to conversational prose
Replace two-pass structured LLM synthesis (## Your Day / ## The World
sections with bullets and formatted news cards) with a single
conversational pass. The new prompt instructs the model to write 3-5
flowing sentences covering weather, today's tasks/events, and 1-2 news
highlights — no markdown, no headers, no lists. Full news detail stays
in the right panel; weather detail stays in the weather card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 13:37:47 -04:00
bvandeusen 2a8c0cfa56 fix: set keep_alive to 2h on all Ollama requests
Prevents models from sitting in VRAM indefinitely. Applies to both
streaming chat calls and the non-streaming generate_completion path,
as well as the startup warm-up request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:21:59 -04:00
bvandeusen dd304bb556 feat: unify voice PTT across briefing and chat views
- ChatView: add PTT mic button in input bar (hold to speak → transcribe → send)
- BriefingView: restyle input bar to match ChatView (floating pill, circle send)
- BriefingView: move listen/mic controls into input bar, remove from header
- BriefingView: consistent icon-button style for speaker/mic matching ChatView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:58:20 -04:00
bvandeusen f146485df3 feat: hot-reload voice models without server restart
Voice enabled/STT model are now DB-backed (admin settings), not env
vars. Added reload_stt_model()/reload_tts_model() that clear singletons
under lock and re-trigger loading. POST /api/admin/voice/reload triggers
both in background tasks. Settings UI polls /api/voice/status every 2.5s
until models are ready, with spinner feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:52:07 -04:00
bvandeusen eaf70500b8 feat: move voice enable/model config to admin UI
Replace VOICE_ENABLED env var gate with DB-backed admin setting.

- services/voice_config.py: reads voice_enabled + voice_stt_model from
  admin user's settings row (falls back to env var defaults)
- routes/admin.py: GET/PUT /api/admin/voice for admin configuration
- routes/voice.py, services/stt.py, services/tts.py: read enabled/model
  from DB via voice_config instead of Config directly
- app.py: always schedule model loaders at startup; they self-gate on
  the DB setting so no conditional needed at the call site
- SettingsView.vue: Voice section in Admin → Config tab (enable toggle +
  STT model dropdown); user Voice tab now points to admin panel when disabled

No env var required to test — enable via Settings → Admin → Config → Voice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:22:18 -04:00
bvandeusen 6f84d90dff feat: voice S2S — faster-whisper STT, Kokoro TTS, PTT overlay
Implements full speech-to-speech pipeline (all 4 phases):

Backend (Phase 1):
- services/stt.py: lazy WhisperModel singleton, run_in_executor transcription
- services/tts.py: lazy KPipeline singleton, WAV synthesis at 24kHz/16-bit
- routes/voice.py: /api/voice/status, /voices, /transcribe, /synthesise
- config.py: VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND env vars
- app.py: load STT/TTS models at startup when VOICE_ENABLED=true
- llm.py: voice_mode + voice_speech_style params inject speak-naturally prefix
- generation_task.py: voice_mode passed through from chat route
- chat.py: "voice" conversation type allowed + excluded from retention cleanup
- pyproject.toml + Dockerfile: faster-whisper, kokoro, soundfile dependencies

Frontend (Phases 2–4):
- composables/useVoiceRecorder.ts: MediaRecorder PTT wrapper
- composables/useVoiceAudio.ts: AudioContext WAV playback wrapper
- BriefingView.vue: Listen button (TTS read-aloud), auto-TTS mode, mic PTT
- VoiceOverlay.vue: global floating PTT button; creates/reuses voice conv;
  full record→transcribe→stream→TTS flow; Space bar hold-to-talk via App.vue
- SettingsView.vue: Voice tab (status badge, speech style, voice/speed)
- App.vue: mounts VoiceOverlay; Space keydown/keyup fires voice:ptt-toggle
- api/client.ts: getVoiceStatus, getVoiceList, transcribeAudio, synthesiseSpeech

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:03:38 -04:00
bvandeusen 3581cc1582 docs: add voice S2S design spec
Covers STT (faster-whisper), TTS (Kokoro), per-user settings,
all new/modified files, audio format decisions, and 4-phase
implementation plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:17:58 -04:00
bvandeusen 3dd879640a chore(fable-mcp): bump version to 0.2.0
Reflects all changes since initial 0.1.0 release: RSS tools, task log
content/body fix, project and milestone status on create, and various
other fixes. Auto-bump hook will handle patch increments from here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:01:12 -04:00
bvandeusen f8b7b51832 Merge pull request 'Release v26.03.29.1 — security fixes and MCP bug fixes' (#18) from dev into main
Release v26.03.29.1 — security fixes and MCP bug fixes
2026-03-29 19:40:35 +00:00
bvandeusen 0cbeb6b7ac fix: honour status param on project and milestone creation
create_project and create_milestone hardcoded status="active" and
ignored any value passed by the MCP or API callers. Route, service,
and model construction now all thread the status field through.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:19:50 -04:00
bvandeusen 218f946e48 fix: fable_add_task_log sends content not body to match API
The MCP tool was sending {"body": ...} but the task logs API route
expects {"content": ...}, causing 400 errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:16:13 -04:00
bvandeusen 00643c778e security: fix 10 vulnerabilities from security audit
- SSRF: block private/internal URLs in image cache fetch
- SSRF: block private/internal URLs in RSS feed fetch (scheme guard)
- SSRF: block private/internal URLs in CalDAV URL setting
- Auth: require login for GET /api/images/<id> (was unauthenticated)
- Auth: restrict Ollama model pull/delete to admin users only
- Info disclosure: remove email from /api/users/search response
- OAuth: skip email-based account linking when email_verified is false
- Config: raise hard error on default SECRET_KEY when SECURE_COOKIES=true
- Rate limit: document proxy header requirement; add startup warning
- XSS: remove src/alt from global DOMPurify ADD_ATTR allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:37:13 -04:00
129 changed files with 15828 additions and 3940 deletions
+9
View File
@@ -121,6 +121,14 @@ jobs:
echo "value=$TAGS" >> $GITHUB_OUTPUT
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
- name: Free disk space
run: |
# Remove all unused images (including old :SHA tags) and containers.
docker system prune -af || true
# Keep the local BuildKit cache bounded so pip mount cache survives
# but stale intermediate layers don't accumulate indefinitely.
docker builder prune --keep-storage 5g -f || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -136,5 +144,6 @@ jobs:
with:
context: .
push: true
provenance: false
tags: ${{ steps.tags.outputs.value }}
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
+1
View File
@@ -0,0 +1 @@
2298268
+12 -3
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Stage 1: Build Vue frontend
FROM node:22-alpine AS build-frontend
WORKDIR /build
@@ -12,14 +13,22 @@ WORKDIR /app
COPY pyproject.toml .
COPY src/ src/
RUN pip install --no-cache-dir .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install .
# Voice dependencies (faster-whisper, Kokoro TTS, soundfile) — activated at runtime via VOICE_ENABLED
# Install CPU-only torch first so pip doesn't pull full CUDA wheels (~2 GB) for kokoro/transformers.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install torch --index-url https://download.pytorch.org/whl/cpu \
&& pip install faster-whisper kokoro soundfile \
&& python -m spacy download en_core_web_sm
# Build the fable-mcp wheel so it can be served for download
COPY fable-mcp/ fable-mcp/
RUN pip install --no-cache-dir build hatchling \
RUN --mount=type=cache,target=/root/.cache/pip \
pip install build hatchling \
&& python -m build --wheel ./fable-mcp --outdir /app/dist/ \
&& pip uninstall -y build \
&& rm -rf fable-mcp/ /root/.cache/pip
&& rm -rf fable-mcp/
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
COPY alembic.ini .
@@ -0,0 +1,53 @@
"""Add user_profiles table for structured per-user preferences."""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
revision = "0034"
down_revision = "0033"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"user_profiles",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("job_title", sa.Text(), nullable=True),
sa.Column("industry", sa.Text(), nullable=True),
sa.Column("expertise_level", sa.Text(), nullable=True),
sa.Column("response_style", sa.Text(), nullable=True),
sa.Column("tone", sa.Text(), nullable=True),
sa.Column("interests", ARRAY(sa.Text()), nullable=True),
sa.Column("work_schedule", JSONB(), nullable=True),
sa.Column("learned_summary", sa.Text(), nullable=True),
sa.Column("observations_raw", JSONB(), nullable=True),
sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True)
def downgrade() -> None:
op.drop_index("ix_user_profiles_user_id", table_name="user_profiles")
op.drop_table("user_profiles")
@@ -0,0 +1,28 @@
"""Add rss_item_embeddings table for semantic news search."""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision = "0035"
down_revision = "0034"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"rss_item_embeddings",
sa.Column("rss_item_id", sa.Integer(), sa.ForeignKey("rss_items.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.func.now()),
)
op.create_index("ix_rss_item_embeddings_user_id", "rss_item_embeddings", ["user_id"])
op.create_index("ix_rss_item_embeddings_rss_item_id", "rss_item_embeddings", ["rss_item_id"])
def downgrade() -> None:
op.drop_index("ix_rss_item_embeddings_rss_item_id", table_name="rss_item_embeddings")
op.drop_index("ix_rss_item_embeddings_user_id", table_name="rss_item_embeddings")
op.drop_table("rss_item_embeddings")
@@ -0,0 +1,28 @@
"""Add note_type and metadata columns to notes table for entity types."""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision = "0036"
down_revision = "0035"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"notes",
sa.Column("note_type", sa.Text(), nullable=False, server_default="note"),
)
op.add_column(
"notes",
sa.Column("metadata", JSONB(), nullable=True),
)
op.create_index("ix_notes_note_type", "notes", ["note_type"])
def downgrade() -> None:
op.drop_index("ix_notes_note_type", table_name="notes")
op.drop_column("notes", "metadata")
op.drop_column("notes", "note_type")
@@ -0,0 +1,29 @@
"""Add reminder_minutes and reminder_sent_at to events.
Revision ID: 0037
Revises: 0036
Create Date: 2026-04-04
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0037"
down_revision = "0036"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("events", sa.Column("reminder_minutes", sa.Integer(), nullable=True))
op.add_column(
"events",
sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("events", "reminder_sent_at")
op.drop_column("events", "reminder_minutes")
+1 -1
View File
@@ -15,7 +15,7 @@ services:
environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
OLLAMA_URL: "http://ollama:11434"
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:8B}"
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
# Uncomment and set to enable web research and image search via SearXNG:
# SEARXNG_URL: "http://searxng:8080"
+200
View File
@@ -0,0 +1,200 @@
# Speech-to-Speech (S2S) Design Spec
**Branch:** `feature/voice-s2s`
**Date:** 2026-03-29
**Status:** Approved for implementation
---
## Decisions
- **STT:** faster-whisper (in-process Python, model size configurable via `STT_MODEL` env var)
- **TTS:** Kokoro TTS (in-process Python, voice/speed/style configurable per-user)
- **Input mode:** Push-to-talk (Phase 1); VAD deferred
- **No browser STT/TTS fallbacks** — self-hosted only, data stays on-server
- **No Android app** — web only for this implementation
---
## New Env Vars (`config.py`)
| Var | Default | Description |
|---|---|---|
| `VOICE_ENABLED` | `false` | Feature flag — opt-in |
| `STT_BACKEND` | `faster-whisper` | Only supported value currently |
| `STT_MODEL` | `base.en` | `tiny.en` / `base.en` / `small.en` / `medium.en` |
| `TTS_BACKEND` | `kokoro` | Only supported value currently |
---
## Per-User Settings (stored in `settings` table)
| Key | Default | Description |
|---|---|---|
| `voice_tts_voice` | `af_heart` | Kokoro voice ID |
| `voice_tts_speed` | `1.0` | Speech rate, 0.71.3 |
| `voice_speech_style` | `conversational` | `conversational` / `concise` / `detailed` |
---
## New Backend Files
### `src/fabledassistant/services/stt.py`
Lazy singleton `WhisperModel` loader. Public API:
- `load_stt_model()` — called at startup via `asyncio.create_task`
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
- `stt_available() -> bool`
### `src/fabledassistant/services/tts.py`
Lazy singleton `KPipeline` loader. Public API:
- `load_tts_model()` — called at startup
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
- `tts_available() -> bool`
### `src/fabledassistant/routes/voice.py`
Blueprint at `/api/voice`, all routes `@login_required`.
| Endpoint | Method | Description |
|---|---|---|
| `/api/voice/status` | GET | STT/TTS availability; `enabled` false if `VOICE_ENABLED=false` |
| `/api/voice/voices` | GET | List available Kokoro voices |
| `/api/voice/transcribe` | POST | multipart `audio` field → `{"transcript": "...", "duration_ms": 123}` |
| `/api/voice/synthesise` | POST | `{"text", "voice", "speed"}` → WAV bytes |
---
## Modified Backend Files
### `src/fabledassistant/app.py`
- Register `voice_bp` blueprint
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
### `src/fabledassistant/config.py`
- Add 4 new env var attributes
- Add validation in `validate()`
### `src/fabledassistant/services/llm.py`
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
- Append style modifier based on `voice_speech_style`
### `src/fabledassistant/services/generation_task.py`
- Add `voice_mode: bool = False` to `run_generation()`
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
### `src/fabledassistant/routes/chat.py`
- Allow `"voice"` in `conversation_type` whitelist
### `src/fabledassistant/services/chat.py`
- Exclude `conversation_type == "voice"` from auto-cleanup retention
---
## New Frontend Files
### `frontend/src/composables/useVoiceRecorder.ts`
Wraps `MediaRecorder`. Exports: `recording`, `error`, `isSupported`, `startRecording()`, `stopRecording() -> Promise<Blob>`.
### `frontend/src/composables/useVoiceAudio.ts`
Wraps `AudioContext`. Exports: `playing`, `isSupported`, `play(blob)`, `stop()`.
### `frontend/src/components/VoiceOverlay.vue`
Floating PTT button (fixed bottom-right). Creates/reuses a `"voice"` conversation. Full flow: record → transcribe → send → stream → synthesise → play. Space bar hotkey (from `App.vue`). Mounted globally in `App.vue`.
---
## Modified Frontend Files
### `frontend/src/api/client.ts`
Add: `transcribeAudio(blob)`, `synthesiseSpeech(text, voice?, speed?)`, `getVoiceStatus()`, `getVoiceList()`
### `frontend/src/views/BriefingView.vue`
- "Listen" button: reads latest assistant message aloud via TTS
- Mic button in input bar: PTT → transcribe → auto-fill input → send
- Auto-TTS on assistant response when in listen mode
### `frontend/src/views/SettingsView.vue`
- New "Voice" tab: voice dropdown, speed slider, speech style radio
- Loads from `/api/settings`, saves via `PUT /api/settings`
### `frontend/src/App.vue`
- Mount `<VoiceOverlay />`
- Space bar → `"voice:ptt-toggle"` custom event
---
## Audio Format
| Direction | Format | Rationale |
|---|---|---|
| Browser → Server | WebM/Opus | Native `MediaRecorder` output; no re-encoding |
| Server → Browser | WAV (24kHz, 16-bit mono) | Kokoro native; no re-encoding; `decodeAudioData` compatible |
---
## Dependencies to Add (`pyproject.toml`)
```toml
[project.optional-dependencies]
voice = [
"faster-whisper>=1.0",
"kokoro>=0.9",
"soundfile>=0.12",
]
```
Install unconditionally in Docker (activated by `VOICE_ENABLED` at runtime):
```dockerfile
RUN pip install faster-whisper kokoro soundfile
```
---
## Database Migration
No schema changes required. `conversation_type` is unconstrained TEXT. Voice settings use existing key-value `settings` table. Optional no-op migration `0034_voice_conversation_type.py` for audit trail.
---
## Implementation Phases
### Phase 1 — Backend services + routes
1. Add env vars to `config.py`
2. Create `services/stt.py` (faster-whisper)
3. Create `services/tts.py` (Kokoro)
4. Create `routes/voice.py` (4 endpoints)
5. Wire model loading into `app.py` startup
6. Add `voice_mode` to `build_context()` + `run_generation()`
7. Allow `"voice"` conversation type in chat route + cleanup exclusion
### Phase 2 — BriefingView listen + voice follow-up
1. Create `useVoiceRecorder.ts`
2. Create `useVoiceAudio.ts`
3. Add voice API functions to `client.ts`
4. Add "Listen" button + mic button to `BriefingView.vue`
### Phase 3 — VoiceOverlay for general voice chat
1. Create `VoiceOverlay.vue`
2. Mount in `App.vue` + Space bar hotkey
### Phase 4 — Settings UI
1. Add "Voice" tab to `SettingsView.vue`
---
## Kokoro Voice Reference
| ID | Character |
|---|---|
| `af_heart` | American female, warm (recommended default) |
| `af_bella` | American female, expressive |
| `af_nicole` | American female, breathy/intimate |
| `af_sarah` | American female, clear |
| `af_sky` | American female, bright |
| `am_adam` | American male, neutral |
| `am_michael` | American male, deeper |
| `bf_emma` | British female |
| `bf_isabella` | British female, formal |
| `bm_george` | British male |
| `bm_lewis` | British male, casual |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,594 @@
# Streaming TTS Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Start playing TTS audio during LLM generation by splitting responses into sentences and synthesizing each sentence as it completes, rather than waiting for the full response.
**Architecture:** A new `useStreamingTts` composable watches `streamingContent` for sentence boundaries, fires per-sentence `synthesiseSpeech` requests concurrently, and plays audio in strict insertion order using `useVoiceAudio`. ChatView, BriefingView, and WorkspaceView all use this composable, replacing their current post-stream speak logic.
**Tech Stack:** Vue 3 Composition API, TypeScript, `useVoiceAudio` (existing), `synthesiseSpeech` from `api/client.ts` (existing), no backend changes.
---
## File Map
| Action | File | Responsibility |
|--------|------|----------------|
| **Create** | `frontend/src/composables/useStreamingTts.ts` | All streaming TTS logic: sentence splitting, TTS queuing, ordered playback |
| **Modify** | `frontend/src/views/ChatView.vue` | Replace `speakLastAssistantMessage` + old watch with `useStreamingTts` |
| **Modify** | `frontend/src/views/BriefingView.vue` | Replace `speakText` + `listenToLatest` + old watch with `useStreamingTts` |
| **Modify** | `frontend/src/views/WorkspaceView.vue` | Add listen mode toggle button + `useStreamingTts` |
---
## Task 1: Create `useStreamingTts` composable
**Files:**
- Create: `frontend/src/composables/useStreamingTts.ts`
- [ ] **Step 1: Create the composable**
Create `frontend/src/composables/useStreamingTts.ts` with the full implementation:
```typescript
import { ref, watch, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { synthesiseSpeech } from '@/api/client'
import { useVoiceAudio } from '@/composables/useVoiceAudio'
/** Minimum stripped character count to bother synthesizing. */
const MIN_CHARS = 3
/** Matches sentence-terminal punctuation followed by whitespace or end-of-string. */
const SENTENCE_BOUNDARY = /[.!?]+(?=\s|$)/
function stripMarkdown(text: string): string {
return text
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
.replace(/#{1,6}\s+/g, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/\n{2,}/g, ' ')
.trim()
}
/**
* Extract completed sentences from `text` using SENTENCE_BOUNDARY.
* Returns the sentences found and the unconsumed remainder.
*/
function extractSentences(text: string): { sentences: string[]; remainder: string } {
const sentences: string[] = []
let remaining = text
let match: RegExpExecArray | null
while ((match = SENTENCE_BOUNDARY.exec(remaining)) !== null) {
const boundary = match.index + match[0].length
const sentence = remaining.slice(0, boundary).trim()
if (sentence) sentences.push(sentence)
remaining = remaining.slice(boundary)
}
return { sentences, remainder: remaining }
}
export interface UseStreamingTtsOptions {
streamingContent: Ref<string> | ComputedRef<string>
streaming: Ref<boolean> | ComputedRef<boolean>
enabled: Ref<boolean> | ComputedRef<boolean>
}
export interface UseStreamingTtsReturn {
/** True while any synthesis request is in-flight or audio is playing. */
speaking: ComputedRef<boolean>
/** Cancel all in-flight synthesis/playback and clear the queue. */
stop: () => void
}
export function useStreamingTts(options: UseStreamingTtsOptions): UseStreamingTtsReturn {
const { streamingContent, streaming, enabled } = options
const audio = useVoiceAudio()
let sentenceBuffer = ''
let lastSeenLength = 0
let abortId = 0
let playQueue: Promise<void> = Promise.resolve()
const pendingCount = ref(0)
const speaking = computed(() => pendingCount.value > 0 || audio.playing.value)
function stop(): void {
abortId++
sentenceBuffer = ''
lastSeenLength = 0
playQueue = Promise.resolve()
audio.stop()
pendingCount.value = 0
}
async function enqueueSentence(sentence: string, myAbortId: number): Promise<void> {
const stripped = stripMarkdown(sentence)
if (stripped.length < MIN_CHARS) return
pendingCount.value++
let blob: Blob | null = null
try {
blob = await synthesiseSpeech(stripped)
} catch (e) {
console.warn('[StreamingTTS] Synthesis failed, retrying sentence', { sentence: stripped, error: e })
try {
blob = await synthesiseSpeech(stripped)
} catch (e2) {
console.warn('[StreamingTTS] Retry also failed, skipping sentence', { sentence: stripped, error: e2 })
}
} finally {
pendingCount.value--
}
if (!blob) return
// Capture blob for the closure — TS can't narrow after async gap
const resolvedBlob = blob
playQueue = playQueue.then(async () => {
if (abortId !== myAbortId) return
await audio.play(resolvedBlob)
})
}
function dispatchBuffer(flush: boolean): void {
if (!enabled.value) return
const myAbortId = abortId
const { sentences, remainder } = extractSentences(sentenceBuffer)
sentenceBuffer = flush ? '' : remainder
for (const sentence of sentences) {
enqueueSentence(sentence, myAbortId)
}
if (flush && remainder.trim().length >= MIN_CHARS) {
enqueueSentence(remainder.trim(), myAbortId)
}
}
// Watch accumulating content — extract new characters since last check
watch(streamingContent, (newContent) => {
if (!enabled.value) return
const delta = newContent.slice(lastSeenLength)
lastSeenLength = newContent.length
sentenceBuffer += delta
dispatchBuffer(false)
})
// Watch streaming flag — stop on new message start, flush on end
watch(streaming, (isStreaming) => {
if (!enabled.value) return
if (isStreaming) {
// New message starting — cancel previous response's audio
stop()
} else {
// Stream ended — flush any remaining fragment
dispatchBuffer(true)
lastSeenLength = 0
}
})
return { speaking, stop }
}
```
- [ ] **Step 2: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1 | head -40
```
Expected: no errors mentioning `useStreamingTts.ts`.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/composables/useStreamingTts.ts
git commit -m "feat(tts): add useStreamingTts composable for sentence-level streaming"
```
---
## Task 2: Update ChatView
**Files:**
- Modify: `frontend/src/views/ChatView.vue`
Current TTS code to remove (lines ~3566):
```typescript
// REMOVE these:
const synthesising = ref(false);
async function speakLastAssistantMessage() { ... } // entire function
watch(() => store.streaming, async (streaming) => {
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
await new Promise((r) => setTimeout(r, 200));
await speakLastAssistantMessage();
}
});
```
Also remove the `synthesiseSpeech` import from `@/api/client` (it is no longer called directly in this file).
- [ ] **Step 1: Add import and replace TTS logic**
In `frontend/src/views/ChatView.vue`:
1. Add to imports at the top of `<script setup>`:
```typescript
import { useStreamingTts } from "@/composables/useStreamingTts";
```
2. Remove `synthesiseSpeech` from the `@/api/client` import line (keep other imports like `apiGet`, `transcribeAudio`).
3. Remove `const synthesising = ref(false);` (line ~35).
4. Remove the entire `speakLastAssistantMessage` function (lines ~3859).
5. Remove the `watch(() => store.streaming, ...)` block that called `speakLastAssistantMessage` (lines ~6166).
6. Add after `const listenMode = useListenMode();`:
```typescript
const tts = useStreamingTts({
streamingContent: computed(() => store.streamingContent),
streaming: computed(() => store.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
});
```
- [ ] **Step 2: Update template references**
In the ChatView template, replace every occurrence of `synthesising` with `tts.speaking.value`:
Find (line ~919):
```html
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }"
```
Replace with:
```html
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': tts.speaking.value }"
```
Find (line ~920):
```html
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()"
```
Replace with:
```html
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
```
Find (line ~924):
```html
<svg v-if="!synthesising && !audio.playing.value" ...>
```
Replace with:
```html
<svg v-if="!tts.speaking.value" ...>
```
Note: the `audio` variable (`useVoiceAudio()`) is still used for the volume slider and PTT stop — do NOT remove it.
- [ ] **Step 3: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1 | head -40
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/views/ChatView.vue
git commit -m "feat(tts): wire useStreamingTts into ChatView"
```
---
## Task 3: Update BriefingView
**Files:**
- Modify: `frontend/src/views/BriefingView.vue`
Current TTS code to remove:
```typescript
// REMOVE:
const synthesising = ref(false)
async function speakText(text: string) { ... } // entire function
async function listenToLatest() { ... } // entire function
// REMOVE this watch block (the TTS one — keep the other streaming watch):
watch(() => chatStore.streaming, async (streaming) => {
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
await new Promise((r) => setTimeout(r, 200))
await listenToLatest()
}
})
```
Note: BriefingView has **two** `watch(() => chatStore.streaming, ...)` blocks. Keep the first one (lines ~152156, which refreshes messages). Remove only the TTS one (lines ~327332).
Also remove the `synthesiseSpeech` import from `@/api/client`.
- [ ] **Step 1: Add import and replace TTS logic**
In `frontend/src/views/BriefingView.vue`:
1. Add to imports:
```typescript
import { useStreamingTts } from '@/composables/useStreamingTts'
```
2. Remove `synthesiseSpeech` from the `@/api/client` import line.
3. Remove `const synthesising = ref(false)`.
4. Remove the entire `speakText` function.
5. Remove the entire `listenToLatest` function.
6. Remove the TTS `watch(() => chatStore.streaming, ...)` block (the one that calls `listenToLatest`).
7. Add after `const listenMode = useListenMode()`:
```typescript
const tts = useStreamingTts({
streamingContent: computed(() => chatStore.streamingContent),
streaming: computed(() => chatStore.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
```
- [ ] **Step 2: Update template references**
Find the listen toggle button in the template. Replace `synthesising` references:
```html
<!-- Before -->
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
<!-- After -->
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': tts.speaking.value }"
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
```
Find the stop button:
```html
<!-- Before -->
v-if="voiceTtsEnabled && (synthesising || audio.playing.value)"
@click="audio.stop(); synthesising = false"
<!-- After -->
v-if="voiceTtsEnabled && tts.speaking.value"
@click="tts.stop()"
```
Find the spinner SVG condition:
```html
<!-- Before -->
<svg v-if="!synthesising && !audio.playing.value" ...>
<!-- After -->
<svg v-if="!tts.speaking.value" ...>
```
- [ ] **Step 3: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1 | head -40
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/views/BriefingView.vue
git commit -m "feat(tts): wire useStreamingTts into BriefingView"
```
---
## Task 4: Add streaming TTS to WorkspaceView
**Files:**
- Modify: `frontend/src/views/WorkspaceView.vue`
WorkspaceView has no TTS today. We add: listen mode toggle, `useStreamingTts`, and the listen button in the chat input toolbar.
- [ ] **Step 1: Add imports and composable**
In `frontend/src/views/WorkspaceView.vue`, add to the import block at the top of `<script setup>`:
```typescript
import { useListenMode } from '@/composables/useListenMode'
import { useStreamingTts } from '@/composables/useStreamingTts'
import { useVoiceAudio } from '@/composables/useVoiceAudio'
```
After the existing store setup code (after `const settingsStore = useSettingsStore()`), add:
```typescript
const listenMode = useListenMode()
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
const audio = useVoiceAudio()
const tts = useStreamingTts({
streamingContent: computed(() => chatStore.streamingContent),
streaming: computed(() => chatStore.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
```
- [ ] **Step 2: Add listen mode button to template**
In the `<div class="chat-input-area">` section (around line 365), add the listen button before the abort/send button:
```html
<div class="chat-input-area">
<textarea
ref="inputEl"
v-model="messageInput"
class="chat-input"
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent… (Enter to send)'"
rows="1"
@keydown="onInputKeydown"
@input="autoResize"
></textarea>
<!-- Listen mode toggle (TTS) -->
<button
v-if="voiceTtsEnabled"
class="btn-listen-ws"
:class="{ 'btn-listen-ws--active': listenMode, 'btn-listen-ws--busy': tts.speaking.value }"
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
aria-label="Toggle listen mode"
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
>
<svg v-if="!tts.speaking.value" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</button>
<button
v-if="chatStore.streaming"
class="btn-abort"
title="Stop generation"
@click="chatStore.cancelGeneration()"
>
■ Stop
</button>
<button
v-else
class="btn-send"
:disabled="!messageInput.trim()"
@click="sendMessage"
>
Send
</button>
</div>
```
- [ ] **Step 3: Add CSS for the listen button**
In the `<style>` block, add:
```css
.btn-listen-ws {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.btn-listen-ws:hover {
color: var(--color-text);
border-color: var(--color-primary);
}
.btn-listen-ws--active {
color: var(--color-primary);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.btn-listen-ws--busy {
color: var(--color-primary);
animation: pulse 1.2s ease-in-out infinite;
}
```
- [ ] **Step 4: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1 | head -40
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/views/WorkspaceView.vue
git commit -m "feat(tts): add streaming TTS listen mode to WorkspaceView"
```
---
## Task 5: Final integration check and push
- [ ] **Step 1: Full TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1
```
Expected: zero errors.
- [ ] **Step 2: Verify no dead imports remain**
```bash
grep -n "synthesiseSpeech\|speakLastAssistantMessage\|speakText\|listenToLatest\|synthesising" \
frontend/src/views/ChatView.vue \
frontend/src/views/BriefingView.vue \
frontend/src/views/WorkspaceView.vue
```
Expected: no matches (all replaced).
- [ ] **Step 3: Manual smoke test**
1. Enable voice in Admin → Config
2. Open Chat, enable listen mode (speaker icon)
3. Send a message and watch: audio should begin playing the first sentence while the LLM is still streaming the response
4. Send another message mid-playback — previous audio should stop immediately
5. Toggle listen mode off mid-response — audio stops, `tts.stop()` called
6. Repeat in `/briefing` and `/workspace/:id`
- [ ] **Step 4: Push**
```bash
git push origin dev
```
---
## Self-Review
**Spec coverage check:**
- ✅ Starts playing during generation (sentence-level queue, fires on each boundary)
- ✅ Automatic when listen mode on (enabled computed = listenMode && voiceTtsEnabled)
- ✅ ChatView updated
- ✅ BriefingView updated
- ✅ WorkspaceView added
- ✅ One retry before skipping on failure
- ✅ Failures logged via `console.warn` with sentence text and error
-`stop()` on new message start (watch streaming → true)
- ✅ Flush remaining buffer on stream end (watch streaming → false)
- ✅ Fragments < 3 chars skipped
-`abortId` prevents stale playback after stop
**Type consistency:**
- `tts.speaking` is `ComputedRef<boolean>` — accessed as `tts.speaking.value` in templates ✅
- `tts.stop()` called consistently across all three views ✅
- `useStreamingTts` options match usage in all three call sites ✅
- `audio` variable kept in ChatView (used by volume slider) — not removed ✅
@@ -0,0 +1,695 @@
# Article Reading Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `read_article` tool so the LLM can fetch any URL, fix the history builder so tool context survives follow-up turns, redesign the Discuss button to inject article content as a persisted tool exchange, and remove the RSS content character cap.
**Architecture:** Four independent changes executed in dependency order: (1) content cap removal, (2) `read_article` tool, (3) history builder fix (prerequisite for everything persisting across follow-ups), (4) Discuss endpoint + frontend. Each task is independently committable.
**Tech Stack:** Python/Quart, SQLAlchemy async, trafilatura (already installed), httpx (already installed), Vue 3 + TypeScript frontend.
---
## File map
| Action | Path | Responsibility |
|---|---|---|
| Modify | `src/fabledassistant/services/rss.py` | Remove `CONTENT_MAX_CHARS` truncation |
| Modify | `src/fabledassistant/services/tools.py` | Add `_URL_TOOLS` list, add `read_article` to `get_tools_for_user`, add handler in `execute_tool` |
| Modify | `src/fabledassistant/routes/chat.py` | Fix history builder to replay tool_calls |
| Modify | `src/fabledassistant/services/chat.py` | Add `tool_calls` parameter to `add_message` |
| Modify | `src/fabledassistant/routes/briefing.py` | Add `POST /api/briefing/articles/<item_id>/discuss` endpoint |
| Modify | `frontend/src/views/BriefingView.vue` | Replace `discussArticle()` to call new endpoint |
| Modify | `tests/test_rss_service.py` | Update truncation test, add no-truncation test |
| Create | `tests/test_article_reading.py` | Tests for `read_article` tool and history builder |
---
## Task 1: Remove RSS content cap
**Files:**
- Modify: `src/fabledassistant/services/rss.py:17-18,83,213`
- Modify: `tests/test_rss_service.py:19-26`
The `CONTENT_MAX_CHARS = 50_000` constant and all uses of `[:CONTENT_MAX_CHARS]` are removed.
Trafilatura extracts only article body text, so content is naturally bounded.
- [ ] **Step 1: Update the truncation test to assert no truncation**
In `tests/test_rss_service.py`, replace the existing `test_extract_item_truncates_content` test:
```python
def test_extract_item_does_not_truncate_content():
"""extract_item() should store content without truncation."""
from fabledassistant.services.rss import extract_item
long_text = "x" * 100_000
entry = MagicMock()
entry.get = lambda k, d="": {"summary": long_text, "title": "", "link": "", "id": "g"}.get(k, d)
entry.content = []
entry.published_parsed = None
item = extract_item(entry)
assert len(item["content"]) == 100_000
```
- [ ] **Step 2: Run the test to confirm it fails**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
make test ARGS="tests/test_rss_service.py::test_extract_item_does_not_truncate_content -v"
```
Expected: FAIL (current code truncates to 50_000).
- [ ] **Step 3: Remove CONTENT_MAX_CHARS from rss.py**
In `src/fabledassistant/services/rss.py`:
Remove lines 1718:
```python
# Safety cap on stored content — effectively unlimited for typical articles
CONTENT_MAX_CHARS = 50_000
```
Change line 83 from:
```python
content = _html_to_text(content)[:CONTENT_MAX_CHARS]
```
to:
```python
content = _html_to_text(content)
```
Change line 213 from:
```python
item.content = full_text[:CONTENT_MAX_CHARS]
```
to:
```python
item.content = full_text
```
- [ ] **Step 4: Run all rss tests**
```bash
make test ARGS="tests/test_rss_service.py -v"
```
Expected: all pass. The `test_extract_item_truncates_content` test name no longer exists (replaced in Step 1).
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/services/rss.py tests/test_rss_service.py
git commit -m "feat(rss): remove article content character cap"
```
---
## Task 2: Add `read_article` tool
**Files:**
- Modify: `src/fabledassistant/services/tools.py`
- Create: `tests/test_article_reading.py`
The tool uses `_fetch_full_article` from `rss.py` (lazy import inside `execute_tool` to avoid circular dependencies). Added unconditionally to all users via a new `_URL_TOOLS` list.
- [ ] **Step 1: Write failing tests**
Create `tests/test_article_reading.py`:
```python
import json
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_read_article_success():
"""read_article tool returns article content on success."""
from fabledassistant.services.tools import execute_tool
with patch(
"fabledassistant.services.rss._fetch_full_article",
new=AsyncMock(return_value="Article text here."),
):
result = await execute_tool(
user_id=1,
tool_name="read_article",
arguments={"url": "https://example.com/article"},
)
assert result["success"] is True
assert result["type"] == "article_content"
assert result["url"] == "https://example.com/article"
assert result["content"] == "Article text here."
assert result["truncated"] is False
@pytest.mark.asyncio
async def test_read_article_fetch_failure():
"""read_article tool returns success=False when fetch returns None."""
from fabledassistant.services.tools import execute_tool
with patch(
"fabledassistant.services.rss._fetch_full_article",
new=AsyncMock(return_value=None),
):
result = await execute_tool(
user_id=1,
tool_name="read_article",
arguments={"url": "https://example.com/bad"},
)
assert result["success"] is False
assert "Could not fetch" in result["error"]
@pytest.mark.asyncio
async def test_read_article_truncates_at_40k():
"""read_article tool truncates content at 40_000 chars and sets truncated=True."""
from fabledassistant.services.tools import execute_tool
long_content = "x" * 50_000
with patch(
"fabledassistant.services.rss._fetch_full_article",
new=AsyncMock(return_value=long_content),
):
result = await execute_tool(
user_id=1,
tool_name="read_article",
arguments={"url": "https://example.com/long"},
)
assert result["success"] is True
assert len(result["content"]) == 40_000
assert result["truncated"] is True
@pytest.mark.asyncio
async def test_read_article_empty_url():
"""read_article tool returns success=False when url is empty."""
from fabledassistant.services.tools import execute_tool
result = await execute_tool(
user_id=1,
tool_name="read_article",
arguments={"url": ""},
)
assert result["success"] is False
```
- [ ] **Step 2: Run tests to confirm they fail**
```bash
make test ARGS="tests/test_article_reading.py -v"
```
Expected: all 4 fail with "read_article not handled" or AttributeError.
- [ ] **Step 3: Add `_URL_TOOLS` list and register it in `get_tools_for_user`**
In `src/fabledassistant/services/tools.py`, add the `_URL_TOOLS` list immediately after the `_SEARCH_TOOLS` block (around line 836):
```python
_URL_TOOLS = [
{
"type": "function",
"function": {
"name": "read_article",
"description": (
"Fetch and read the full text of a web page or article from a URL. "
"Use when the user shares a URL and wants you to read it, or to get "
"the full content of a linked page. "
"Do NOT use search_web for URLs — use this tool instead."
),
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to fetch and read"}
},
"required": ["url"],
},
},
}
]
```
In `get_tools_for_user` (around line 1034), add `_URL_TOOLS` unconditionally after `_CORE_TOOLS`:
```python
async def get_tools_for_user(user_id: int) -> list[dict]:
"""Build the tool list for a user based on their configured integrations."""
tools = list(_CORE_TOOLS)
tools.extend(_URL_TOOLS)
tools.extend(_RAG_TOOLS)
tools.extend(_ENTITY_TOOLS)
if await is_caldav_configured(user_id):
tools.extend(_CALDAV_TOOLS)
if Config.searxng_enabled():
tools.extend(_SEARCH_TOOLS)
tools.extend(_RESEARCH_TOOLS)
tools.extend(_IMAGE_TOOLS)
logger.debug("User %d: %d tools available", user_id, len(tools))
return tools
```
- [ ] **Step 4: Add `read_article` handler in `execute_tool`**
In `src/fabledassistant/services/tools.py`, in the `execute_tool` function, find the `elif tool_name == "search_web":` block (around line 1771). Add the new handler immediately before it:
```python
elif tool_name == "read_article":
from fabledassistant.services.rss import _fetch_full_article
url = arguments.get("url", "").strip()
if not url:
return {"success": False, "error": "No URL provided"}
content = await _fetch_full_article(url)
if not content:
return {"success": False, "error": f"Could not fetch article content from {url}"}
_TOOL_CONTENT_CAP = 40_000
truncated = len(content) > _TOOL_CONTENT_CAP
return {
"success": True,
"type": "article_content",
"url": url,
"content": content[:_TOOL_CONTENT_CAP],
"truncated": truncated,
}
```
- [ ] **Step 5: Run the tests**
```bash
make test ARGS="tests/test_article_reading.py -v"
```
Expected: all 4 pass.
- [ ] **Step 6: Run full test suite**
```bash
make test
```
Expected: all pass.
- [ ] **Step 7: Commit**
```bash
git add src/fabledassistant/services/tools.py tests/test_article_reading.py
git commit -m "feat(tools): add read_article tool using trafilatura extraction"
```
---
## Task 3: Fix history builder
**Files:**
- Modify: `src/fabledassistant/routes/chat.py:162-166`
- Modify: `tests/test_article_reading.py` (add history builder tests)
The loop that builds `history` for `run_generation` currently drops `tool_calls`. This fix replays the full tool exchange so the LLM sees prior tool results on follow-up turns.
- [ ] **Step 1: Add history builder tests**
Append to `tests/test_article_reading.py`:
```python
def test_history_builder_plain_messages():
"""Messages without tool_calls are added as {role, content} unchanged."""
import json
messages = [
type("M", (), {"role": "system", "content": "sys", "tool_calls": None})(),
type("M", (), {"role": "user", "content": "hello", "tool_calls": None})(),
type("M", (), {"role": "assistant", "content": "hi", "tool_calls": None})(),
]
history = _build_history(messages)
assert history == [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
def test_history_builder_with_tool_calls():
"""Messages with tool_calls emit an assistant entry + tool result entries."""
import json
tool_calls_data = [
{
"function": "read_article",
"arguments": {"url": "https://example.com"},
"result": {"success": True, "content": "Article text"},
}
]
messages = [
type("M", (), {"role": "user", "content": "read this", "tool_calls": None})(),
type("M", (), {
"role": "assistant",
"content": "",
"tool_calls": tool_calls_data,
})(),
type("M", (), {"role": "user", "content": "follow up", "tool_calls": None})(),
]
history = _build_history(messages)
assert history[0] == {"role": "user", "content": "read this"}
assert history[1]["role"] == "assistant"
assert history[1]["tool_calls"] == [
{"function": {"name": "read_article", "arguments": {"url": "https://example.com"}}}
]
assert history[2] == {"role": "tool", "content": json.dumps({"success": True, "content": "Article text"})}
assert history[3] == {"role": "user", "content": "follow up"}
def _build_history(messages):
"""Inline copy of the fixed history builder for testing."""
import json
history = []
for msg in messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
return history
```
- [ ] **Step 2: Run the tests to confirm they pass**
(These tests use `_build_history` defined inline — they test the logic directly, not the route. They should pass immediately.)
```bash
make test ARGS="tests/test_article_reading.py::test_history_builder_plain_messages tests/test_article_reading.py::test_history_builder_with_tool_calls -v"
```
Expected: both pass.
- [ ] **Step 3: Apply the fix to `chat.py`**
In `src/fabledassistant/routes/chat.py`, replace lines 162166:
```python
# Build history from existing messages (excluding system and the placeholder)
history = []
for msg in conv.messages:
if msg.role != "system":
history.append({"role": msg.role, "content": msg.content})
```
with:
```python
# Build history from existing messages (excluding system and the placeholder).
# Tool calls from prior turns are replayed as assistant tool_call + tool result
# messages so the LLM retains tool context on follow-up turns.
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
```
`json` is already imported at the top of `chat.py`.
- [ ] **Step 4: Run full test suite**
```bash
make test
```
Expected: all pass.
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/routes/chat.py tests/test_article_reading.py
git commit -m "fix(chat): replay tool_calls in history so tool context survives follow-up turns"
```
---
## Task 4: Extend `add_message` to accept `tool_calls`
**Files:**
- Modify: `src/fabledassistant/services/chat.py:183-207`
The Discuss endpoint (Task 5) needs to store a synthetic assistant message with `tool_calls`. The existing `add_message` doesn't support this parameter.
- [ ] **Step 1: Update `add_message` signature and body**
In `src/fabledassistant/services/chat.py`, replace the `add_message` function (lines 183207):
```python
async def add_message(
conversation_id: int,
role: str,
content: str,
context_note_id: int | None = None,
status: str | None = None,
tool_calls: list | None = None,
) -> Message:
async with async_session() as session:
kwargs: dict = dict(
conversation_id=conversation_id,
role=role,
content=content,
context_note_id=context_note_id,
)
if status is not None:
kwargs["status"] = status
if tool_calls is not None:
kwargs["tool_calls"] = tool_calls
msg = Message(**kwargs)
session.add(msg)
# Touch conversation updated_at
conv = await session.get(Conversation, conversation_id)
if conv:
conv.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(msg)
return msg
```
- [ ] **Step 2: Run full test suite**
```bash
make test
```
Expected: all pass (existing callers only use positional/keyword args that are unchanged).
- [ ] **Step 3: Commit**
```bash
git add src/fabledassistant/services/chat.py
git commit -m "feat(chat): add tool_calls parameter to add_message"
```
---
## Task 5: Add Discuss endpoint and update frontend
**Files:**
- Modify: `src/fabledassistant/routes/briefing.py`
- Modify: `frontend/src/views/BriefingView.vue`
New route: `POST /api/briefing/articles/<item_id>/discuss`. Fetches stored article from DB, stores a synthetic `read_article` tool exchange plus the user message, then triggers generation. Frontend replaces the inline-content approach with a call to this endpoint.
- [ ] **Step 1: Add the discuss endpoint to briefing.py**
At the top of `src/fabledassistant/routes/briefing.py`, add these imports (after the existing imports):
```python
from fabledassistant.models.rss_feed import RssItem, RssFeed
from fabledassistant.services.chat import add_message, get_conversation
from fabledassistant.services.generation_buffer import GenerationState, create_buffer, get_buffer
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.settings import get_setting
```
Note: `get_setting` and `asyncio` are already imported. Add only what is missing.
Then add the new route at the end of `briefing.py` (before any final lines), after the `list_news` route:
```python
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_article(item_id: int):
"""Pre-load a briefing article as a read_article tool exchange and trigger generation."""
uid = g.user.id
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id is required"}), 400
# Verify article belongs to this user (via feed ownership)
async with async_session() as session:
result = await session.execute(
select(RssItem).join(RssFeed, RssItem.feed_id == RssFeed.id)
.where(RssItem.id == item_id, RssFeed.user_id == uid)
)
item = result.scalar_one_or_none()
if item is None:
return jsonify({"error": "Article not found"}), 404
# Verify conversation belongs to this user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
# Reject if generation already running
existing = get_buffer(conv_id)
if existing and existing.state == GenerationState.RUNNING:
return jsonify({"error": "Generation already in progress"}), 409
article_content = item.content or ""
# Store synthetic assistant message: read_article was already called with stored content
synthetic_tool_calls = [
{
"function": "read_article",
"arguments": {"url": item.url},
"result": {
"success": True,
"type": "article_content",
"url": item.url,
"content": article_content,
"truncated": False,
},
}
]
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
# Store user message
await add_message(conv_id, "user", "Please summarize and discuss this article.")
# Reload conversation so history includes the two new messages
conv = await get_conversation(uid, conv_id)
# Build history (using the fixed builder from chat.py logic — duplicated here)
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", "") or ""
from fabledassistant.config import Config as _Config
if not model:
model = _Config.OLLAMA_MODEL
# Create placeholder assistant message and generation buffer
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
try:
buf = create_buffer(conv_id, assistant_msg.id)
except RuntimeError:
return jsonify({"error": "Generation already in progress"}), 409
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title,
"Please summarize and discuss this article.",
think=True,
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
```
- [ ] **Step 2: Run full test suite**
```bash
make test
```
Expected: all pass.
- [ ] **Step 3: Update `discussArticle` in BriefingView.vue**
In `frontend/src/views/BriefingView.vue`, replace the `discussArticle` function:
```typescript
async function discussArticle(item: NewsItem) {
if (!todayConvId.value || chatStore.streaming) return
if (!isToday.value) selectedConvId.value = todayConvId.value
await nextTick(() => {
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
try {
await apiPost<{ assistant_message_id: number }>(
`/api/briefing/articles/${item.id}/discuss`,
{ conv_id: todayConvId.value },
)
} catch {
return
}
// Reload conversation so the new messages appear (including the generating placeholder),
// then reconnect to the SSE stream using the existing reconnectIfGenerating helper.
await chatStore.fetchConversation(todayConvId.value)
await chatStore.reconnectIfGenerating(todayConvId.value)
}
```
`reconnectIfGenerating` is already exported from `useChatStore`. It finds the assistant message in `status="generating"` state and connects to the SSE stream automatically. No changes to `chat.ts` are needed.
- [ ] **Step 4: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
npm --prefix frontend run type-check
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/routes/briefing.py frontend/src/views/BriefingView.vue frontend/src/stores/chat.ts
git commit -m "feat(briefing): add discuss endpoint and update frontend to use persisted article context"
```
---
## Task 6: Final verification
- [ ] **Step 1: Run full test suite**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
make test
```
Expected: all tests pass.
- [ ] **Step 2: TypeScript check**
```bash
npm --prefix frontend run type-check
```
Expected: no errors.
- [ ] **Step 3: Push**
```bash
git push origin dev
```
@@ -0,0 +1,479 @@
# Web Voice Overlay Polish — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it in `App.vue`, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection backed by a new `useSilenceDetector` composable.
**Architecture:** A new `useSilenceDetector` composable uses `AudioContext` + `AnalyserNode` to monitor amplitude from a live `MediaStream` and fires a callback after sustained silence. `VoiceOverlay` coordinates recording and silence detection, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds a Space bar handler that dispatches the existing `voice:ptt-toggle` custom event.
**Tech Stack:** Vue 3 Composition API, TypeScript, Web Audio API (`AudioContext`, `AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables.
---
## File Map
| Action | Path |
|--------|------|
| Create | `frontend/src/composables/useSilenceDetector.ts` |
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
| Modify | `frontend/src/components/VoiceOverlay.vue` |
| Modify | `frontend/src/App.vue` |
---
### Task 1: `useSilenceDetector` composable
**Files:**
- Create: `frontend/src/composables/useSilenceDetector.ts`
**Context:** The Web Audio API lets us pipe a `MediaStream` into an `AnalyserNode` and read frequency data as a byte array every 100 ms. RMS amplitude of that array gives a 01 loudness value; converting to dB lets us use the same `-40 dB` threshold as the Android app. The composable must be safe to call `stop()` on multiple times and must reset amplitude to 0 after stopping so the animated bars collapse.
- [ ] **Step 1: Create the file with full implementation**
`frontend/src/composables/useSilenceDetector.ts`:
```ts
import { ref, readonly } from 'vue'
export interface SilenceDetectorOptions {
thresholdDb?: number // default -40
silenceDurationMs?: number // default 1500
minRecordingMs?: number // default 500
}
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
const {
thresholdDb = -40,
silenceDurationMs = 1500,
minRecordingMs = 500,
} = options
const amplitude = ref(0)
let audioCtx: AudioContext | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let silenceMs = 0
let startedAt = 0
function start(stream: MediaStream, onSilence: () => void): void {
stop()
audioCtx = new AudioContext()
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
silenceMs = 0
startedAt = Date.now()
intervalId = setInterval(() => {
analyser.getByteFrequencyData(data)
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
amplitude.value = rms
const db = rms > 0 ? 20 * Math.log10(rms) : -100
if (db < thresholdDb) {
silenceMs += 100
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
stop()
onSilence()
}
} else {
silenceMs = 0
}
}, 100)
}
function stop(): void {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
if (audioCtx) {
audioCtx.close().catch(() => {})
audioCtx = null
}
amplitude.value = 0
silenceMs = 0
}
return { amplitude: readonly(amplitude), start, stop }
}
```
- [ ] **Step 2: Verify TypeScript compiles**
```bash
cd /path/to/fabledassistant/frontend
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/composables/useSilenceDetector.ts
git commit -m "feat: add useSilenceDetector composable with Web Audio API amplitude monitoring"
```
---
### Task 2: Expose `stream` ref from `useVoiceRecorder`
**Files:**
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
**Context:** Currently `stream` is a plain `let` variable inside the closure. `VoiceOverlay` needs to pass the live `MediaStream` to `useSilenceDetector.start()` after recording begins. Exposing it as a readonly `Ref<MediaStream | null>` is the minimal change — no other callers are broken because they don't currently read `stream` from the return value.
The current file is at `frontend/src/composables/useVoiceRecorder.ts`. Read it before editing — the key lines to change are:
1. Top of function body: `let stream: MediaStream | null = null``const streamRef = ref<MediaStream | null>(null)`
2. In `startRecording()`: `stream = await navigator.mediaDevices.getUserMedia({ audio: true })``streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })`
3. In `startRecording()` catch block: `stream = null` if present — replace with `streamRef.value = null` (if the catch sets stream to null; if not, skip)
4. In `mediaRecorder.onstop`: `stream?.getTracks().forEach((t) => t.stop())``streamRef.value?.getTracks().forEach((t) => t.stop())` then `streamRef.value = null`
5. Return object: add `stream: readonly(streamRef)`
- [ ] **Step 1: Add the `ref` import if not already present**
The file already imports `{ ref, readonly }` from `'vue'` — confirm this. If `ref` is missing from the import, add it.
- [ ] **Step 2: Replace the `stream` variable declaration**
Find:
```ts
let stream: MediaStream | null = null
```
Replace with:
```ts
const streamRef = ref<MediaStream | null>(null)
```
- [ ] **Step 3: Update all usages of `stream` in `startRecording`**
Find:
```ts
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
```
Replace with:
```ts
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
```
- [ ] **Step 4: Update `onstop` handler**
Find:
```ts
stream?.getTracks().forEach((t) => t.stop())
stream = null
```
Replace with:
```ts
streamRef.value?.getTracks().forEach((t) => t.stop())
streamRef.value = null
```
- [ ] **Step 5: Add `stream` to the return object**
Find the return statement and add `stream: readonly(streamRef)`:
```ts
return {
recording: readonly(recording),
error: readonly(error),
isSupported,
startRecording,
stopRecording,
stream: readonly(streamRef),
}
```
- [ ] **Step 6: Verify TypeScript compiles**
```bash
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 7: Commit**
```bash
git add frontend/src/composables/useVoiceRecorder.ts
git commit -m "feat: expose live stream ref from useVoiceRecorder"
```
---
### Task 3: Update `VoiceOverlay` — silence detection, click-to-toggle, amplitude bars
**Files:**
- Modify: `frontend/src/components/VoiceOverlay.vue`
**Context:** `VoiceOverlay.vue` is a complete floating voice UI that was never mounted. It currently uses `@mousedown`/`@mouseup` for push-to-talk. This task switches it to click-to-toggle with automatic silence detection and adds animated amplitude bars during recording. Read the full file before making changes — the existing structure and style blocks must be preserved.
#### Script changes
- [ ] **Step 1: Import `useSilenceDetector`**
At the top of `<script setup>`, after the existing imports, add:
```ts
import { useSilenceDetector } from '@/composables/useSilenceDetector'
```
- [ ] **Step 2: Instantiate the composable**
After `const audio = useVoiceAudio()`, add:
```ts
const silenceDetector = useSilenceDetector()
```
- [ ] **Step 3: Update `startPtt` to start silence detection**
Find the `startPtt` function. After `phase.value = 'recording'`, add:
```ts
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, stopPtt)
}
```
The complete `startPtt` after the change:
```ts
async function startPtt() {
if (!voiceEnabled.value || isBusy.value) return
audio.stop()
errorMsg.value = ''
open.value = true
await recorder.startRecording()
if (recorder.error.value) {
phase.value = 'error'
errorMsg.value = recorder.error.value
return
}
phase.value = 'recording'
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, stopPtt)
}
}
```
- [ ] **Step 4: Update `stopPtt` to stop silence detection**
Add `silenceDetector.stop()` as the very first line of `stopPtt`:
```ts
async function stopPtt() {
silenceDetector.stop()
if (phase.value !== 'recording') return
// ... rest unchanged
```
- [ ] **Step 5: Update `cancelAll` to stop silence detection**
Add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`:
```ts
function cancelAll() {
silenceDetector.stop()
recorder.stopRecording().catch(() => {})
audio.stop()
phase.value = 'idle'
streamContent.value = ''
errorMsg.value = ''
}
```
- [ ] **Step 6: Add `onBtnClick` function**
Add this function after `cancelAll`:
```ts
function onBtnClick() {
if (phase.value === 'error') { phase.value = 'idle'; return }
if (phase.value === 'recording') { stopPtt(); return }
if (phase.value === 'idle') { startPtt() }
}
```
#### Template changes
- [ ] **Step 7: Replace PTT mouse/touch handlers with `@click`**
On `.voice-ptt-btn`, replace:
```html
@mousedown.prevent="startPtt"
@mouseup.prevent="stopPtt"
@touchstart.prevent="startPtt"
@touchend.prevent="stopPtt"
@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"
```
with:
```html
@click.prevent="onBtnClick"
```
- [ ] **Step 8: Update aria-label and title on the button**
Replace:
```html
:aria-label="phase === 'recording' ? 'Release to send' : 'Hold to speak'"
:title="phase === 'recording' ? 'Release to send' : 'Hold Space or tap to speak'"
```
with:
```html
:aria-label="phase === 'recording' ? 'Click to stop' : 'Click to speak'"
:title="phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'"
```
- [ ] **Step 9: Replace the static recording icon with amplitude bars**
Find:
```html
<!-- Recording: waveform / stop icon -->
<svg v-else-if="phase === 'recording'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
```
Replace with:
```html
<!-- Recording: amplitude bars -->
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
<span
v-for="n in 3"
:key="n"
class="voice-amp-bar"
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
></span>
</span>
```
- [ ] **Step 10: Update the idle hint label**
Find:
```html
Hold <kbd>Space</kbd> or tap
```
Replace with:
```html
Tap or press <kbd>Space</kbd>
```
#### Style changes
- [ ] **Step 11: Add amplitude bar styles to `<style scoped>`**
Append inside the `<style scoped>` block:
```css
/* ─── Amplitude bars (recording state) ──────────────────────────────────── */
.voice-amp-bars {
display: flex;
gap: 3px;
align-items: center;
height: 22px;
}
.voice-amp-bar {
width: 4px;
height: 18px;
background: #fff;
border-radius: 2px;
transform-origin: center;
transition: transform 0.08s ease;
}
```
- [ ] **Step 12: Verify TypeScript compiles**
```bash
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 13: Commit**
```bash
git add frontend/src/components/VoiceOverlay.vue
git commit -m "feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay"
```
---
### Task 4: Mount `VoiceOverlay` and wire Space bar in `App.vue`
**Files:**
- Modify: `frontend/src/App.vue`
**Context:** `App.vue` has a full `onGlobalKeydown` handler and a shortcuts overlay. The Space bar is already documented there as "Hold to speak (voice, when enabled)" but the handler was never added to `onGlobalKeydown`. `VoiceOverlay` uses `Teleport to="body"` so it renders at the document root regardless of where it's placed in the template — just needs to be inside the authenticated block.
#### Script changes
- [ ] **Step 1: Add `VoiceOverlay` import**
In `<script setup>`, after the existing component imports (after `ToastNotification`), add:
```ts
import VoiceOverlay from '@/components/VoiceOverlay.vue'
```
- [ ] **Step 2: Add Space bar case to `onGlobalKeydown`**
The existing handler has a `switch (e.key)` block. The guard `if (isInputActive() || e.ctrlKey || e.metaKey || e.altKey) return` already runs before the switch, so the Space case only fires when the user isn't typing.
Inside the `switch (e.key)` block, add this case after the existing `'c'` case:
```ts
case ' ':
e.preventDefault()
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
break
```
#### Template changes
- [ ] **Step 3: Mount `VoiceOverlay` in the authenticated template**
Find `<ToastNotification />` near the bottom of the authenticated template block and add `<VoiceOverlay />` directly above it:
```html
<VoiceOverlay />
<ToastNotification />
```
- [ ] **Step 4: Update Space bar description in shortcuts panel**
Find:
```html
<span class="shortcut-desc">Hold to speak (voice, when enabled)</span>
```
Replace with:
```html
<span class="shortcut-desc">Tap to speak (voice, when enabled)</span>
```
- [ ] **Step 5: Verify TypeScript compiles**
```bash
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 6: Verify full build succeeds**
```bash
npm run build
```
Expected: build completes with no errors.
- [ ] **Step 7: Manual smoke test**
1. Start the dev server: `npm run dev`
2. Log in — confirm the floating mic button appears in the bottom-right corner
3. Ensure voice is enabled in Settings → Voice
4. Click the mic button — confirm it turns red with animated amplitude bars
5. Speak — bars should animate with your voice
6. Stop speaking — after ~1.5 s of silence, the button should switch to purple (transcribing), then green (speaking) as it plays back the response
7. Click the mic while recording — confirm it stops immediately
8. Press Space (not in an input field) — confirm it starts/stops recording
9. Press Space in the chat input — confirm it does NOT trigger voice
- [ ] **Step 8: Commit**
```bash
git add frontend/src/App.vue
git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut in App.vue"
```
@@ -0,0 +1,216 @@
# ChatPanel Unification Design
**Date:** 2026-04-03
## Goal
Replace the four divergent chat surfaces (ChatView, BriefingView, WorkspaceView, HomeView widget) with a single `ChatPanel` component that encapsulates all chat behaviour — streaming, TTS, PTT, tool calls, thinking blocks, abort — so that fixes and features automatically apply to every context.
---
## Background
The app currently has four independent chat implementations that have drifted significantly:
| Surface | File | Gap |
|---|---|---|
| Main chat | `ChatView.vue` | Canonical reference |
| Briefing | `BriefingView.vue` | Had separate TTS impl (now fixed), no PTT, streaming race bug |
| Workspace | `WorkspaceView.vue` | TTS missing until recently, different input wiring |
| Dashboard widget | `HomeView.vue` + `DashboardChatInput.vue` | Separate input component, response rendered manually in parent, no TTS, no PTT |
Every fix to chat has required touching 34 files. This design makes chat a first-class component.
---
## Architecture
### Component: `ChatPanel.vue`
A single Vue 3 component that owns the entire chat interaction loop for a given conversation context. Two variants controlled by a `variant` prop:
- **`full`** — full-height chat: message history, streaming bubble, input bar, all controls
- **`widget`** — compact embedded chat: input bar + compact response area, no history scroll
Both variants share identical internals: same composables, same store reads, same TTS/PTT/abort logic.
### Extracted Sub-components
| Component | Responsibility |
|---|---|
| `ChatInputBar.vue` | Unified input bar: textarea, note picker, PTT mic, send button, abort button |
| `ChatMessageList.vue` | Scrollable message history with auto-scroll, bulk-select (full variant only) |
| `ChatStreamingBubble.vue` | Live streaming content display + thinking block |
| `ChatToolCallList.vue` | Tool call cards, collapsed/expanded state |
### State Ownership
`ChatPanel` reads from `useChatStore` directly — it does not accept messages or streaming state as props. This mirrors how all current views work and avoids prop-drilling re-implementation.
The conversation being displayed is controlled via a `convId` prop. When `convId` is undefined, `ChatPanel` uses `chatStore.currentConversationId`. The parent view sets up the conversation (creates it if needed) and passes the ID down.
---
## Props & Emits Interface
```typescript
interface ChatPanelProps {
variant: 'full' | 'widget'
convId?: number // which conversation to display; undefined = store current
projectId?: number // workspace: pins RAG scope, passed to sendMessage
briefingMode?: boolean // briefing: hides RAG scope chip, enables briefing-specific send path
placeholder?: string // input placeholder text
autoFocus?: boolean // focus input on mount
}
interface ChatPanelEmits {
// Emitted when a new conversation is started from the widget (so parent can track convId)
(e: 'conversation-started', convId: number): void
}
```
All other behaviour (TTS, PTT, thinking, tool calls, streaming indicator, abort) is always on — not gated by props. The intentional differences between views are expressed only through the props above.
---
## Variant Behaviour
### `variant="full"` (ChatView, BriefingView, WorkspaceView)
Layout (top to bottom):
```
┌────────────────────────────────────────┐
│ [RAG scope chip / briefing header] │ ← shown unless briefingMode or projectId set
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ChatMessageList │
│ user bubble │
│ assistant bubble + tool calls │
│ thinking block (always shown) │
│ ... │
│ ChatStreamingBubble (while streaming) │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ChatInputBar │
│ [textarea] [note-picker] [mic] [▶] │
│ [listen toggle] [abort] │
└────────────────────────────────────────┘
```
### `variant="widget"` (HomeView dashboard)
Layout (top to bottom, compact):
```
┌────────────────────────────────────────┐
│ ChatInputBar (pill style) │
│ [textarea] [mic] [▶] │
├────────────────────────────────────────┤
│ [query text] (after send) │
│ [streaming / final response text] │
│ [tool call chips] │
│ [Continue in Chat →] │
└────────────────────────────────────────┘
```
The widget variant does NOT show full message history. It shows only the most recent exchange. Once a new conversation is started or the user navigates to `/chat/:id`, the full history is available.
The `.dashboard-response` section currently in `HomeView.vue` moves inside `ChatPanel` and is rendered when `variant="widget"` and a conversation exists.
---
## TTS / PTT Wiring
`ChatPanel` instantiates `useStreamingTts` and `useListenMode` internally. These are not passed as props.
```typescript
// Inside ChatPanel setup()
const listenMode = useListenMode()
const voiceTtsEnabled = computed(() => /* same check as current views */)
const tts = useStreamingTts({
streamingContent: computed(() => chatStore.streamingContent),
streaming: computed(() => !!chatStore.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
```
PTT is handled inside `ChatInputBar` via the existing `useVoiceRecorder` composable (already used in `DashboardChatInput`). On recording stop, the transcribed text is placed in the textarea and auto-submitted.
---
## Per-View Migration
### ChatView → `<ChatPanel variant="full">`
- Remove: all TTS/PTT/streaming/abort logic, scroll management, input bar template
- Keep: route wiring, conversation list sidebar, bulk-delete UI (sidebar stays in ChatView)
- ChatPanel replaces only the right-hand panel
### BriefingView → `<ChatPanel variant="full" briefingMode />`
- Remove: streaming watch, TTS, manual scroll, input bar, response persistence workaround
- Keep: history dropdown (today / past briefings), date header
- `briefingMode` hides the RAG scope chip
### WorkspaceView → `<ChatPanel variant="full" :projectId="projectId">`
- Remove: inline chat input, streaming watch, TTS wiring
- Keep: 3-panel grid layout, task panel, note editor panel
- ChatPanel takes the centre column
### HomeView → `<ChatPanel variant="widget">`
- Remove: `DashboardChatInput` import + usage, `.dashboard-response` section, all manual store wiring (`dashboardConvId`, `dashboardQuery`, `dashboardFinalContent`, `dashboardFinalToolCalls`, `onChatSubmit`)
- Keep: dashboard layout, projects/tasks/events sections
- `DashboardChatInput.vue` deleted
---
## Data Flow
```
Parent view
└─ <ChatPanel :convId="convId" variant="full|widget">
├─ reads: useChatStore (messages, streaming, streamingContent, currentConversation)
├─ ChatMessageList — renders history from store
├─ ChatStreamingBubble — renders chatStore.streamingContent while streaming
├─ ChatToolCallList — renders tool calls from streaming + finalized messages
├─ ChatInputBar
│ ├─ usePtt (mic → textarea → auto-send)
│ └─ emits: submit(content, contextNoteId)
├─ useStreamingTts (sentence-chunk TTS during streaming)
└─ useListenMode (shared global toggle)
```
---
## Files Created / Modified
**Created:**
- `frontend/src/components/ChatPanel.vue`
- `frontend/src/components/ChatInputBar.vue`
- `frontend/src/components/ChatMessageList.vue`
- `frontend/src/components/ChatStreamingBubble.vue`
- (no new composable needed — PTT uses existing `useVoiceRecorder.ts`)
**Modified:**
- `frontend/src/views/ChatView.vue` — use ChatPanel for the chat area
- `frontend/src/views/BriefingView.vue` — replace chat section with ChatPanel
- `frontend/src/views/WorkspaceView.vue` — replace inline chat with ChatPanel
- `frontend/src/views/HomeView.vue` — replace DashboardChatInput + response section with ChatPanel widget
**Deleted:**
- `frontend/src/components/DashboardChatInput.vue`
---
## CSS / Styling
- `ChatPanel` carries its own scoped CSS for both variants
- `ChatInputBar` replicates the pill style currently in `DashboardChatInput` and the flat style in `ChatView` — variant is controlled by a `pill` boolean prop (default false; widget sets it true)
- All existing UI design language tokens (`--color-primary`, `--radius-lg`, Fraunces labels, gradient send button) are preserved
---
## What Does NOT Change
- Chat store (`useChatStore`) — unchanged
- API client (`client.ts`) — unchanged
- Backend routes — unchanged
- WorkspaceTaskPanel and WorkspaceNoteEditor — unchanged
- Briefing history dropdown and date header — unchanged
- ChatView conversation sidebar and bulk-delete — unchanged
- RAG scope chip logic — moved inside ChatPanel, behaviour identical
@@ -0,0 +1,101 @@
# Streaming TTS Design
**Date:** 2026-04-03
**Status:** Approved
## Goal
Start playing TTS audio during LLM generation rather than waiting for the full response to finish. When listen mode is on, the first sentence plays as soon as Kokoro finishes synthesizing it — while the LLM is still streaming the rest of the response.
## Approach
Client-side sentence queuing composable. The frontend accumulates streaming tokens, detects sentence boundaries, fires per-sentence synthesis requests concurrently, and plays audio in strict insertion order. The existing `/api/voice/synthesise` backend endpoint is unchanged.
## Architecture
### `useStreamingTts` composable
**File:** `frontend/src/composables/useStreamingTts.ts`
**Inputs:**
- `streamingContent: Ref<string>` — the growing accumulated response text (e.g. `store.streamingContent`)
- `streaming: Ref<boolean>` — whether the LLM is currently generating
- `enabled: Ref<boolean>``true` when listen mode is on AND TTS is available
**Exports:**
- `speaking: Ref<boolean>``true` while any synthesis is in-flight or audio is playing
- `stop()` — cancels all pending synthesis/playback and clears the queue
**Internal state:**
- `sentenceBuffer: string` — accumulates characters since the last dispatched sentence
- `lastSeenLength: number` — tracks how far into `streamingContent` we've processed
- `abortId: number` — incremented on `stop()`; each queued promise checks the current id and bails if stale
- `playQueue: Promise<void>` — a chained promise that serializes audio playback in insertion order
**Sentence detection:**
- Regex: `/[.!?]+(?=\s|$)/` — handles `...`, `?!`, multi-punctuation
- Triggered on every `streamingContent` change and on `streaming` flipping `false` (flush)
- Fragments < 3 characters after markdown stripping are skipped
**Per-sentence pipeline:**
1. Strip markdown (same logic as current `speakLastAssistantMessage`)
2. Fire `synthesiseSpeech(sentence)` immediately — runs concurrently with other sentences
3. On failure: one immediate retry. If retry also fails, skip silently and advance the queue
4. Resolved blob is inserted into the playback queue at its original position
5. Playback queue plays blobs strictly in insertion order via `useVoiceAudio`
**Stream-end flush:**
- When `streaming` flips `false`, any remaining `sentenceBuffer` content (fragment without terminal punctuation) is dispatched as a final sentence — covers responses that end without a period
**Automatic reset:**
- When `streaming` flips `true` (new message starting), `stop()` is called automatically to cancel any in-flight audio from the previous response before starting fresh
### Views updated
| View | Change |
|------|--------|
| `ChatView.vue` | Replace `speakLastAssistantMessage()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
| `BriefingView.vue` | Replace `speakText()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
| `WorkspaceView.vue` | Add listen mode toggle button (same UI pattern as ChatView) + `useStreamingTts` wired to workspace chat stream |
In all three views: the `speaking` export from `useStreamingTts` replaces the old `synthesising || audio.playing.value` checks for button busy state.
### Backend
No changes. `/api/voice/synthesise` accepts shorter sentence-length strings without issue.
## Error Handling
| Scenario | Behavior |
|----------|----------|
| Synthesis fails for a sentence | One immediate retry; if retry fails, sentence is skipped, queue advances, and a `console.warn` is emitted with the sentence index and error |
| `stop()` called mid-queue | `abortId` incremented; all in-flight promises check id and discard their result |
| New message starts while audio playing | `watch(streaming, true → ...)` calls `stop()` before starting new queue |
| TTS unavailable or listen mode off | Composable is inert — watchers do nothing, no requests fired |
| Fragment < 3 chars after stripping | Skipped without a TTS request |
| Response ends without terminal punctuation | Remaining buffer flushed as final sentence on stream-end |
## Data Flow
```
LLM SSE chunks → store.streamingContent (grows)
useStreamingTts watcher
sentenceBuffer accumulation
sentence boundary detected? → synthesiseSpeech(sentence) [concurrent]
↓ ↓ (fail → 1 retry → skip)
playQueue.then(play blob) resolved blob
useVoiceAudio.play() [sequential]
audio output
```
## Files Changed
- **New:** `frontend/src/composables/useStreamingTts.ts`
- **Modified:** `frontend/src/views/ChatView.vue` — swap TTS logic for composable
- **Modified:** `frontend/src/views/BriefingView.vue` — swap TTS logic for composable
- **Modified:** `frontend/src/views/WorkspaceView.vue` — add listen mode + composable
@@ -0,0 +1,227 @@
# Article Reading Design
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow the LLM to fetch and read the full text of any URL on demand, fix conversation history so tool context survives follow-up turns, and make the briefing Discuss button inject article content as a persisted tool exchange rather than raw user-message text.
**Architecture:** Four self-contained changes — history reconstruction fix (prerequisite), `read_article` tool, Discuss endpoint, and content cap removal.
**Tech Stack:** Python/Quart backend, trafilatura (already installed), SQLAlchemy async, Vue 3 frontend.
---
## Problem summary
Three interrelated issues observed in briefing conversations:
1. **Missing `read_article` tool** — when a user pastes a URL, the LLM calls `search_web` (a SearXNG text search), which returns generic site descriptions instead of article content.
2. **History reconstruction bug**`routes/chat.py:166` builds the `history` list with only `role` + `content`, silently dropping all `tool_calls` and their results from prior turns. Tool context is lost on every follow-up.
3. **Discuss button UX** — inlines raw article text into the user message bubble. Feels clumsy, and the model sometimes searches notes on follow-ups anyway because the article isn't clearly marked as "loaded" context.
---
## Components
### 1. History reconstruction fix
**File:** `src/fabledassistant/routes/chat.py`
The loop at line ~164 that builds `history` must be updated to replay tool exchanges:
```python
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
```
The `tool_calls` JSONB column already stores `[{function, arguments, result}]` per call. No schema change needed.
### 2. `read_article` tool
**Files:** `src/fabledassistant/services/research.py`, `src/fabledassistant/services/tools.py`, `src/fabledassistant/services/rss.py`
Move `_fetch_full_article` from `rss.py` to `research.py` (imported back into `rss.py` to avoid breaking existing calls). This makes it available to `execute_tool` without a circular import.
Tool definition added to `_TOOLS` in `tools.py`:
```python
{
"type": "function",
"function": {
"name": "read_article",
"description": (
"Fetch and read the full text of a web page or article from a URL. "
"Use when the user shares a URL and wants you to read it, "
"or to get the full content of a linked page. "
"Do not use search_web for URLs — use this tool instead."
),
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to fetch"}
},
"required": ["url"],
},
},
}
```
`execute_tool` handler:
```python
elif tool_name == "read_article":
from fabledassistant.services.research import _fetch_full_article
url = arguments.get("url", "").strip()
if not url:
return {"success": False, "error": "No URL provided"}
content = await _fetch_full_article(url)
if not content:
return {"success": False, "error": f"Could not fetch article content from {url}"}
TOOL_CONTENT_CAP = 40_000
truncated = len(content) > TOOL_CONTENT_CAP
return {
"success": True,
"type": "article_content",
"url": url,
"content": content[:TOOL_CONTENT_CAP],
"truncated": truncated,
}
```
### 3. `add_message` — add `tool_calls` parameter
**File:** `src/fabledassistant/services/chat.py`
`add_message` needs to accept and store `tool_calls` so the Discuss endpoint can create synthetic messages:
```python
async def add_message(
conversation_id: int,
role: str,
content: str,
context_note_id: int | None = None,
status: str | None = None,
tool_calls: list | None = None,
) -> Message:
```
Set `msg.tool_calls = tool_calls` when provided.
### 4. Discuss endpoint
**File:** `src/fabledassistant/routes/briefing.py`
New route: `POST /api/briefing/articles/<int:item_id>/discuss`
Request body: `{"conv_id": <int>}`
Steps:
1. Look up `rss_items` row by `item_id` — verify it belongs to the user via feed ownership. Return 404 if not found.
2. Look up conversation by `conv_id` — verify it belongs to the user. Return 404 if not found.
3. If generation already running for `conv_id` → return 409.
4. Fetch stored content: `article_content = item.content or item.snippet or ""`
5. Store synthetic assistant message (status=`"complete"`, role=`"assistant"`, content=`""`, tool_calls as below):
```python
synthetic_tool_calls = [{
"function": "read_article",
"arguments": {"url": item.url},
"result": {
"success": True,
"type": "article_content",
"url": item.url,
"content": article_content,
"truncated": False,
},
}]
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
```
6. Store user message: `await add_message(conv_id, "user", "Please summarize and discuss this article.")`
7. Build `history` from `conv.messages` (using the fixed builder above).
8. Create assistant placeholder, create buffer, launch `run_generation` as normal.
9. Return `{"assistant_message_id": ..., "status": "generating"}` 202.
### 5. Frontend: BriefingView.vue
**File:** `frontend/src/views/BriefingView.vue`
Replace `discussArticle()`:
```typescript
async function discussArticle(item: NewsItem) {
if (!todayConvId.value) return
if (!isToday.value) selectedConvId.value = todayConvId.value
await nextTick(() => {
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
await apiClient.post(`/api/briefing/articles/${item.id}/discuss`, {
conv_id: todayConvId.value,
})
// Re-fetch conversation so the new messages appear, then start SSE streaming.
// The existing chatStore.fetchConversation + startStreaming pattern handles this.
await chatStore.fetchConversation(todayConvId.value)
chatStore.startStreaming(todayConvId.value)
}
```
The exact method names (`fetchConversation`, `startStreaming`) should match what `BriefingView.vue` already uses for the reply flow — confirm during implementation.
The article no longer appears as wall-of-text in the user bubble. The chat UI shows it as a `read_article` tool call card (already handled by `ToolCallCard.vue`).
### 6. Content cap removal
**File:** `src/fabledassistant/services/rss.py`
Remove `[:CONTENT_MAX_CHARS]` from:
- `content = _html_to_text(content)[:CONTENT_MAX_CHARS]` in `extract_item()`
- `item.content = full_text[:CONTENT_MAX_CHARS]` in the enrichment task
The `CONTENT_MAX_CHARS` constant can be removed entirely. Trafilatura extracts only article body text (typically 2K15K chars for news articles), so content is naturally bounded.
---
## Data flow
### User pastes a URL in chat
1. User sends message with a URL
2. LLM calls `read_article(url)`
3. `execute_tool` calls `_fetch_full_article(url)` → trafilatura extracts clean text
4. Tool result appended in-memory as `{role: "tool", content: json}`
5. LLM responds based on article content
6. Generation saves assistant message with `tool_calls=[{function:"read_article", arguments, result}]`
7. Follow-up turns: history builder replays tool_call + tool result → article stays in context
### User clicks Discuss on a briefing article
1. Frontend calls `POST /api/briefing/articles/{item_id}/discuss` with `{conv_id}`
2. Backend fetches stored article text from DB (no network request)
3. Backend stores synthetic assistant message with `read_article` tool result
4. Backend stores user message `"Please summarize and discuss this article."`
5. Generation runs — LLM sees pre-loaded article in history
6. Follow-ups retain context via fixed history builder
---
## Error handling
| Scenario | Behaviour |
|---|---|
| `_fetch_full_article` returns `None` (network/extraction failure) | Tool returns `{success: False, error: "Could not fetch article content from [url]"}` — LLM reports conversationally |
| Discuss: `item_id` not found or wrong user | 404 |
| Discuss: `conv_id` not found or wrong user | 404 |
| Discuss: article has no stored content | Falls back to empty string — LLM works with what it has |
| Discuss: generation already running | 409 |
| Messages with `tool_calls = None` | History builder unchanged — no regression for existing conversations |
---
## Tests
- **Unit:** `_fetch_full_article` returns `None` → `read_article` tool result has `success: False`
- **Unit:** History builder with a stored message that has `tool_calls` → output includes assistant tool_call dict + a `{role: "tool"}` dict
- **Unit:** History builder with messages where `tool_calls = None` → output unchanged from current behaviour
- **Integration:** `POST /api/briefing/articles/{item_id}/discuss` → two messages stored (synthetic assistant + user message), generation triggered, returns 202
@@ -0,0 +1,162 @@
# Research Pipeline — Multi-Note Redesign
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the single monolithic research note with a set of focused, topic-driven notes plus an index note that links them — making research output browsable, TTS-friendly, and well-organized.
**Architecture:** Two new LLM calls (outline generation + N parallel section syntheses) replace the single large synthesis call. Public API unchanged — callers receive the index note. Fallback to single-note behavior on any outline failure.
**Tech Stack:** Python/Quart backend, existing `research.py` service, asyncio.gather for parallelism.
---
## Problem
The current pipeline synthesizes one note with a minimum of 2500 words and 6 sections. This creates:
- Notes too large to read or listen to comfortably
- No way to navigate directly to a specific sub-topic
- TTS failures on long prose (8000-char route limit, unbounded sentence buffers)
---
## Pipeline Flow
Public signature unchanged:
```python
async def run_research_pipeline(
topic: str,
user_id: int,
model: str,
buf=None,
project_id: int | None = None,
) -> Note: # returns the index note
```
Execution order:
```
1. Generate sub-queries (unchanged)
2. Search + fetch sources (unchanged)
3. Generate topic outline (NEW — one LLM call → 37 section dicts)
4. Synthesize each section note (NEW — parallelized via asyncio.gather)
5. Create all section notes in DB (sequential, tagged ["research"], same project_id)
6. Create index note (NEW — links all sections)
7. Return index note
```
Status messages via `buf.append_event("status", ...)`:
- `"Generating outline…"`
- `"Writing: [Section Title]…"` (one per section, emitted before synthesis starts)
- `"Saving [N] notes…"`
No note content is streamed into chat. After the tool call resolves, the LLM writes a brief conversational summary citing the index note title and section count.
---
## Outline Generation
New function: `_generate_outline(topic, sources, model) -> list[dict]`
Sends all fetched sources to the model with a prompt requesting a JSON array:
```json
[
{"title": "Quantum Entanglement: Mechanisms", "focus": "How entanglement works at the physical level"},
{"title": "Quantum Computing Hardware", "focus": "Ion traps, superconducting qubits, photonic approaches"}
]
```
**Prompt requirements:**
- Produce 37 sections covering distinct aspects of the topic
- Titles must work as standalone note titles (no "Overview" or "Introduction" generics)
- No overlap between sections
- `focus` is one sentence describing what this section should specifically cover
**Guardrails:**
- Fewer than 3 sections parsed → fall back to single-note synthesis
- JSON parse failure → fall back to single-note synthesis
- More than 8 sections → truncate to 8
**Model params:** `max_tokens=400, num_ctx=16384` (outline is short)
---
## Section Synthesis
New function: `_synthesize_section(section_title, section_focus, sources, model) -> tuple[str, str]`
Returns `(title, body_markdown)`.
All sections receive all fetched sources. The `section_focus` field in the prompt directs the model to draw only what's relevant to that section's scope.
**Prompt requirements:**
- 300600 words of substantive prose
- Do NOT include a `# Title` heading (title is set separately)
- End with a brief `## Sources` list of relevant URLs from the provided sources
- Focus strictly on `section_focus` — ignore source material outside that scope
**Model params:** `num_predict=2048, num_ctx=16384` (reduced from 8192 — sufficient for 600 words, prevents rambling)
**Parallelism:** All section synthesis calls run via `asyncio.gather`. Wall-clock time stays close to a single synthesis call despite producing N notes.
---
## Note Creation and Index Note
**Section notes:**
- Tags: `["research"]`
- `project_id`: same as passed to pipeline (or None)
- Title: from outline `title` field
- Created sequentially (avoids DB contention)
**Index note:**
- Tags: `["research", "research-index"]`
- `project_id`: same as section notes
- Title: `"Research: [topic]"`
- Created last (after all section notes exist)
**Index note body format:**
```markdown
Research overview for **[topic]** — [YYYY-MM-DD]
Generated from [N] web sources across [M] sections.
## Sections
- **[Section 1 Title]** — [focus sentence]
- **[Section 2 Title]** — [focus sentence]
...
*Search for any section title to read it.*
```
The index note is what `run_research_pipeline` returns. The existing `research_topic` tool handler uses `note.id` and `note.title` — both remain valid with the index note.
---
## Error Handling
| Scenario | Behaviour |
|---|---|
| Outline generation raises | Fall back to single-note synthesis (current behaviour) |
| Outline JSON unparseable | Fall back to single-note synthesis |
| Outline returns < 3 sections | Fall back to single-note synthesis |
| Outline returns > 8 sections | Truncate to 8, continue |
| A section synthesis raises | Log warning, skip that section; continue with remaining |
| All section syntheses fail | Fall back to single-note synthesis |
| A section note DB save fails | Log warning, skip from index; index note still created |
| No sources fetched | Raise `ValueError` as today — unchanged |
The fallback in every case is the current single-note pipeline. Research never silently produces nothing.
---
## What Is NOT Changing
- Public function signature of `run_research_pipeline`
- Sub-query generation (`_generate_sub_queries`)
- SearXNG search and URL fetching
- `_search_searxng`, `_search_searxng_images`, `fetch_url_content`
- The `research_topic` tool definition and handler in `tools.py`
- The `quick_capture` research path
- Any frontend component
@@ -0,0 +1,204 @@
# Settings Consistency Pass — Design
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix five interrelated gaps in the settings UI — missing timezone field, SSO-unaware account tab, duplicated work schedule, ignored slot toggles, and timezone changes not propagating to the briefing scheduler.
**Architecture:** Primarily frontend cleanup with two focused backend hooks: settings PUT route gains a timezone→scheduler bridge; briefing scheduler gains slot-gating and work-day awareness.
**Tech Stack:** Vue 3 + TypeScript frontend; Python/Quart backend; APScheduler; `zoneinfo`.
---
## Problem summary
1. **No timezone field**`user_timezone` is read by the scheduler and the chat pipeline but is never exposed in the UI. The briefing tab displays the browser's detected timezone but never persists it. Scheduler falls back to UTC.
2. **Account tab ignores SSO** — "Email Address" and "Change Password" sections are shown to SSO users (`has_password = false`) even though they cannot change credentials here.
3. **Work schedule duplicated** — Profile tab has the canonical work schedule (days + start/end time, stored in `profile.work_schedule`). Briefing tab has a redundant "Office Days" section (`briefing_config.work_days`) that the backend never reads.
4. **Slot toggles are decorative** — The briefing tab's four slot checkboxes are saved to `briefing_config.slots` but `_add_user_jobs` schedules all four slots unconditionally.
5. **Timezone setting not propagated**`PUT /api/settings` saves `user_timezone` to the DB but does not call `update_user_schedule`, so the in-memory scheduler keeps the stale timezone until restart or briefing config re-save.
---
## Components
### 1. General tab — Timezone field
**File:** `frontend/src/views/SettingsView.vue`
New section in the General tab (after the Assistant section, before Model Management):
```html
<section class="settings-section full-width">
<h2>Timezone</h2>
<p class="section-desc">Used to schedule briefings and format times in chat.</p>
<div class="field">
<label for="user-timezone">Your timezone</label>
<div style="display:flex; gap:0.5rem; align-items:center">
<input id="user-timezone" v-model="userTimezone" type="text"
class="input" placeholder="e.g. America/New_York" />
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
</div>
<p class="field-hint">IANA timezone name (e.g. America/Chicago, Europe/London).</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
{{ savingTimezone ? 'Saving…' : 'Save' }}
</button>
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
</div>
</section>
```
- `detectTimezone()` sets `userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone`
- `saveTimezone()` calls `PUT /api/settings` with `{ user_timezone: userTimezone }`
- Loaded in `onMounted` / general settings load alongside `assistantName`, `defaultModel`
The briefing tab's "Firing in timezone" hint changes from the live Intl API to reading the stored `user_timezone` value:
```
Firing in timezone: <strong>{{ userTimezone || 'UTC (not set)' }}</strong>
```
### 2. Account tab — SSO guard
**File:** `frontend/src/views/SettingsView.vue`
Wrap the Email and Password sections:
```html
<!-- SSO info banner (shown when no local password) -->
<section v-if="!authStore.user?.has_password" class="settings-section">
<h2>Account</h2>
<p class="section-desc">
Your account is managed by an external identity provider.
Email and password changes are made through your provider, not here.
</p>
</section>
<!-- Local-auth sections (hidden for SSO) -->
<template v-if="authStore.user?.has_password">
<section class="settings-section"> <!-- Email Address --> </section>
<section class="settings-section"> <!-- Change Password --> </section>
</template>
<!-- Active Sessions — always shown -->
<section class="settings-section"> ... </section>
```
No backend change needed — the API already rejects email/password changes for SSO accounts.
### 3. Briefing tab — Remove Office Days
**File:** `frontend/src/views/SettingsView.vue`
Delete the "Office Days" `<section>` (lines ~20682082). The `briefing_config.work_days` field can remain in the config object for backwards compatibility but the UI stops writing it.
The slot toggles section stays — it now actually drives scheduling (see §5).
### 4. Backend — settings PUT propagates timezone to scheduler
**File:** `src/fabledassistant/routes/settings.py`
After `set_settings_batch`, add:
```python
if "user_timezone" in to_save:
import json
from fabledassistant.services.briefing_scheduler import update_user_schedule
config_raw = await get_setting(uid, "briefing_config", "{}")
try:
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
except Exception:
config = {}
if config.get("enabled"):
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
```
### 5. Backend — scheduler respects slot toggles and work days
**File:** `src/fabledassistant/services/briefing_scheduler.py`
**5a. `_add_user_jobs` — only schedule enabled slots**
Change signature to accept `config: dict`:
```python
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
enabled_slots = (config or {}).get("slots", {})
for slot_name, hour, minute in SLOTS:
# Default True for compilation (always run); others respect toggle
if slot_name != "compilation" and not enabled_slots.get(slot_name, True):
jid = _job_id(user_id, slot_name)
if _scheduler and _scheduler.get_job(jid):
_scheduler.remove_job(jid)
continue
_scheduler.add_job(
_run_user_slot_sync,
CronTrigger(hour=hour, minute=minute, timezone=tz),
args=[user_id, slot_name],
id=_job_id(user_id, slot_name),
replace_existing=True,
misfire_grace_time=3600,
)
```
Update callers:
- `update_user_schedule(user_id, config, tz_override)` → pass `config` to `_add_user_jobs`
- `start_briefing_scheduler` startup loop → fetch full config to pass through
**5b. `_run_slot_for_user` — skip morning on non-work days**
For the `morning` slot, check today against `profile.work_schedule.days`:
```python
if slot == "morning":
from fabledassistant.services.user_profile import get_profile
from datetime import datetime
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_str)
except Exception:
user_tz = ZoneInfo("UTC")
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
profile = await get_profile(user_id)
work_days = (profile.work_schedule or {}).get("days", ["Mon","Tue","Wed","Thu","Fri"])
if today_abbr not in work_days:
logger.info("Skipping morning slot for user %d%s not a work day", user_id, today_abbr)
return
```
Note: `get_profile` must be importable from `user_profile.py` — confirm signature during implementation.
---
## Data flow
1. User opens Settings → General tab loads, reads `user_timezone` from `GET /api/settings`, populates the field
2. User clicks Detect → browser timezone fills the field
3. User clicks Save → `PUT /api/settings {user_timezone: "America/New_York"}` → backend saves and immediately calls `update_user_schedule` if briefing enabled
4. Briefing tab "Firing in timezone" now shows stored value instead of live browser API
5. Next 8am job: scheduler checks if `morning` is enabled in `briefing_config.slots`, then checks if today is in `profile.work_schedule.days` before running
---
## Error handling
| Scenario | Behaviour |
|---|---|
| `user_timezone` saved as empty string | `update_user_schedule` called with `tz_override=None` → falls back to `briefing_config.timezone` or UTC |
| Invalid IANA string saved | `_resolve_timezone` already falls back to UTC with a warning log |
| `profile.work_schedule` is None | `morning` slot defaults to MonFri |
| Slot toggles key missing from config | All non-compilation slots default to enabled (`True`) — no regression for existing users |
| SSO user visits Account tab | Sees info banner; email/password forms hidden; no API calls attempted |
---
## What is NOT changing
- Profile "Interests" and Briefing "News Preferences" remain separate — they serve different purposes (system-prompt personalisation vs RSS topic filtering)
- `briefing_config.work_days` field is not deleted from existing configs — just stops being written by the UI
- No migration needed — `profile.work_schedule.days` already exists; scheduler change is additive
@@ -0,0 +1,278 @@
# Web Voice Overlay Polish — Implementation Spec
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection.
**Architecture:** A new `useSilenceDetector` composable wraps the Web Audio API `AnalyserNode` and fires a callback when sustained silence is detected. `VoiceOverlay` coordinates `useVoiceRecorder` and `useSilenceDetector`, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds the Space bar handler.
**Tech Stack:** Vue 3 Composition API, Web Audio API (`AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables, TypeScript.
---
## File Map
| Action | Path |
|--------|------|
| Create | `frontend/src/composables/useSilenceDetector.ts` |
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
| Modify | `frontend/src/components/VoiceOverlay.vue` |
| Modify | `frontend/src/App.vue` |
---
## Task 1: `useSilenceDetector` composable
**Files:**
- Create: `frontend/src/composables/useSilenceDetector.ts`
### Interface
```ts
export interface SilenceDetectorOptions {
thresholdDb?: number // default -40
silenceDurationMs?: number // default 1500
minRecordingMs?: number // default 500
}
export function useSilenceDetector(options?: SilenceDetectorOptions): {
amplitude: Readonly<Ref<number>> // 01, for visualization
start(stream: MediaStream, onSilence: () => void): void
stop(): void
}
```
### Behaviour
- `start(stream, onSilence)`:
1. Creates `AudioContext`
2. `createMediaStreamSource(stream)` → connects to `AnalyserNode` (fftSize 256)
3. Records `startedAt = Date.now()`
4. Starts a `setInterval` at 100ms that:
- Calls `analyser.getByteFrequencyData(dataArray)`
- Computes RMS amplitude → maps to 01 range for `amplitude.value`
- Converts to approximate dB: `db = 20 * log10(rms)` (clamp to -100 when rms === 0)
- If `db < thresholdDb`: increments `silenceMs += 100`; else resets `silenceMs = 0`
- If `silenceMs >= silenceDurationMs` AND `Date.now() - startedAt >= minRecordingMs`: clears interval, fires `onSilence()`
- `stop()`: clears interval, closes `AudioContext`, resets `amplitude.value = 0`
- Safe to call `stop()` multiple times (guard with null check)
- `amplitude` resets to 0 after `stop()`
### Full implementation
```ts
import { ref, readonly } from 'vue'
export interface SilenceDetectorOptions {
thresholdDb?: number
silenceDurationMs?: number
minRecordingMs?: number
}
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
const {
thresholdDb = -40,
silenceDurationMs = 1500,
minRecordingMs = 500,
} = options
const amplitude = ref(0)
let audioCtx: AudioContext | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let silenceMs = 0
let startedAt = 0
function start(stream: MediaStream, onSilence: () => void) {
stop()
audioCtx = new AudioContext()
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
silenceMs = 0
startedAt = Date.now()
intervalId = setInterval(() => {
analyser.getByteFrequencyData(data)
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
amplitude.value = rms
const db = rms > 0 ? 20 * Math.log10(rms) : -100
if (db < thresholdDb) {
silenceMs += 100
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
stop()
onSilence()
}
} else {
silenceMs = 0
}
}, 100)
}
function stop() {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
if (audioCtx) {
audioCtx.close().catch(() => {})
audioCtx = null
}
amplitude.value = 0
silenceMs = 0
}
return { amplitude: readonly(amplitude), start, stop }
}
```
- [ ] Write the file exactly as above
- [ ] Verify TypeScript compiles: `cd frontend && npx tsc --noEmit`
- [ ] Commit: `git add frontend/src/composables/useSilenceDetector.ts && git commit -m "feat: add useSilenceDetector composable"`
---
## Task 2: Expose `stream` from `useVoiceRecorder`
**Files:**
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
Change the `stream` local variable to a `Ref<MediaStream | null>` and export it as readonly.
- [ ] Change `let stream: MediaStream | null = null` to `const streamRef = ref<MediaStream | null>(null)`
- [ ] Replace all `stream` assignments with `streamRef.value`:
- `stream = await navigator.mediaDevices.getUserMedia(...)``streamRef.value = await ...`
- `stream?.getTracks().forEach(...)``streamRef.value?.getTracks().forEach(...)`
- `stream = null``streamRef.value = null`
- [ ] Add `stream: readonly(streamRef)` to the return object
- [ ] Verify TypeScript: `npx tsc --noEmit`
- [ ] Commit: `git add frontend/src/composables/useVoiceRecorder.ts && git commit -m "feat: expose stream ref from useVoiceRecorder"`
---
## Task 3: Wire `VoiceOverlay` — silence detection + click-to-toggle
**Files:**
- Modify: `frontend/src/components/VoiceOverlay.vue`
### Script changes
- [ ] Import `useSilenceDetector` at the top of `<script setup>`
- [ ] Add `const silenceDetector = useSilenceDetector()` after the existing composable instantiations
- [ ] In `startPtt()`: after `phase.value = 'recording'`, add:
```ts
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, stopPtt)
}
```
- [ ] In `stopPtt()`: add `silenceDetector.stop()` as the first line (before the guard check)
- [ ] In `cancelAll()`: add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`
### Button: click-to-toggle
Replace the PTT mouse/touch handlers on `.voice-ptt-btn` with click-to-toggle logic:
- [ ] Remove `@mousedown.prevent="startPtt"` and `@mouseup.prevent="stopPtt"`
- [ ] Remove `@touchstart.prevent="startPtt"` and `@touchend.prevent="stopPtt"`
- [ ] Replace `@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"` with:
```html
@click.prevent="onBtnClick"
```
- [ ] Add `onBtnClick` function in script:
```ts
function onBtnClick() {
if (phase.value === 'error') { phase.value = 'idle'; return }
if (phase.value === 'recording') { stopPtt(); return }
if (phase.value === 'idle') { startPtt() }
}
```
- [ ] Update `aria-label` and `title` on the button:
- `aria-label`: `phase === 'recording' ? 'Click to stop' : 'Click to speak'`
- `title`: `phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'`
### Amplitude visualization during recording
Inside the button, when `phase === 'recording'`, replace the static stop icon with animated amplitude bars:
- [ ] Replace the recording SVG block:
```html
<svg v-else-if="phase === 'recording'" ...>...</svg>
```
with:
```html
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
<span
v-for="n in 3"
:key="n"
class="voice-amp-bar"
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
></span>
</span>
```
### Hint label
- [ ] Change the idle hint from `Hold <kbd>Space</kbd> or tap` to `Tap or press <kbd>Space</kbd>`
### CSS for amplitude bars
- [ ] Add to `<style scoped>`:
```css
.voice-amp-bars {
display: flex;
gap: 3px;
align-items: center;
height: 22px;
}
.voice-amp-bar {
width: 4px;
height: 18px;
background: #fff;
border-radius: 2px;
transform-origin: center;
transition: transform 0.08s ease;
}
```
- [ ] Verify TypeScript: `npx tsc --noEmit`
- [ ] Commit: `git add frontend/src/components/VoiceOverlay.vue && git commit -m "feat: click-to-toggle silence detection in VoiceOverlay"`
---
## Task 4: Mount overlay and wire Space bar in `App.vue`
**Files:**
- Modify: `frontend/src/App.vue`
### Mount VoiceOverlay
- [ ] Add import at top of `<script setup>`:
```ts
import VoiceOverlay from '@/components/VoiceOverlay.vue'
```
- [ ] Add `<VoiceOverlay />` inside the `<template v-if="authStore.isAuthenticated">` block, just before `<ToastNotification />`:
```html
<VoiceOverlay />
<ToastNotification />
```
### Space bar handler
- [ ] In `onGlobalKeydown`, add a `Space` case inside the `switch (e.key)` block (after the existing cases), only fires when `!isInputActive()`:
```ts
case ' ':
e.preventDefault()
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
break
```
### Shortcuts panel label
- [ ] Update the Space shortcut description from `Hold to speak (voice, when enabled)` to `Tap to speak (voice, when enabled)`
- [ ] Verify TypeScript: `npx tsc --noEmit`
- [ ] Verify full build: `npm run build`
- [ ] Commit: `git add frontend/src/App.vue && git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut"`
+4 -2
View File
@@ -36,7 +36,7 @@ class FableClient:
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=30.0,
timeout=httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=10.0),
)
return self
@@ -91,7 +91,9 @@ class FableClient:
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
"""Yield non-empty lines from a streaming GET response (SSE)."""
async with self._http().stream("GET", path, **kwargs) as response:
self._raise_for_status(response)
if response.is_error:
await response.aread()
self._raise_for_status(response)
async for line in response.aiter_lines():
if line:
yield line
+3 -3
View File
@@ -298,7 +298,7 @@ async def fable_update_task(
@mcp.tool()
async def fable_add_task_log(task_id: int, body: str) -> dict:
async def fable_add_task_log(task_id: int, content: str) -> dict:
"""Append a timestamped progress log entry to a Fable task.
Use this to record work sessions, decisions, or status updates over time without
@@ -306,7 +306,7 @@ async def fable_add_task_log(task_id: int, body: str) -> dict:
chronologically in the task view.
"""
async with FableClient() as client:
return await tasks.add_task_log(client, task_id=task_id, body=body)
return await tasks.add_task_log(client, task_id=task_id, content=content)
# ---------------------------------------------------------------------------
@@ -565,7 +565,7 @@ async def fable_get_app_logs(
"""Fetch Fable application logs. Requires an admin-scoped API key.
Args:
category: Log category — "error" (default), "audit", or "usage".
category: Log category — "error" (default), "audit", "usage", or "generation".
limit: Maximum number of log entries to return.
search: Optional keyword filter matched against action, endpoint, username, details.
+1 -1
View File
@@ -12,7 +12,7 @@ async def get_app_logs(
) -> dict:
"""Fetch application logs from Fable. Requires an admin-scoped API key.
category: "error" | "audit" | "usage" (default: "error")
category: "error" | "audit" | "usage" | "generation" (default: "error")
"""
params: dict = {"category": category, "limit": limit}
if search:
+42 -22
View File
@@ -1,10 +1,11 @@
"""MCP tools for Fable chat — create conversations and stream responses."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from fable_mcp.client import FableClient
from fable_mcp.client import FableClient, FableAPIError
async def list_conversations(
@@ -28,7 +29,12 @@ async def send_message(
"""Send a message to Fable and return the full assistant response.
Creates a new MCP conversation if conversation_id is None.
Streams the SSE response and accumulates token chunks into a single string.
Posts the user message to start generation, then streams the SSE buffer.
SSE event format:
id: <N>
event: <type> # chunk | done | tool_call | status | ...
data: <json>
Returns:
Dict with:
@@ -41,32 +47,46 @@ async def send_message(
"/api/chat/conversations",
json={"conversation_type": "mcp"},
)
conversation_id = conv["id"]
conversation_id = str(conv["id"])
# Build SSE stream URL with message as query param
params: dict[str, Any] = {"message": message}
if think:
params["think"] = "true"
# Start generation
await client.post(
f"/api/chat/conversations/{conversation_id}/messages",
json={"content": message, "think": think},
)
tokens: list[str] = []
tool_calls: list[Any] = []
stream_path = f"/api/chat/conversations/{conversation_id}/stream"
async for raw_line in client.stream_get(stream_path, params=params):
if not raw_line.startswith("data: "):
continue
payload_str = raw_line[len("data: "):]
stream_path = f"/api/chat/conversations/{conversation_id}/generation/stream"
# Retry connecting to the stream briefly — the background task may not have
# created the generation buffer by the time we issue the GET.
for attempt in range(10):
try:
event = json.loads(payload_str)
except json.JSONDecodeError:
continue
event_type = event.get("type")
if event_type == "token":
tokens.append(event.get("content", ""))
elif event_type == "tool_call":
tool_calls.append(event)
elif event_type == "done":
break
event_type: str | None = None
async for raw_line in client.stream_get(stream_path):
if raw_line.startswith("event: "):
event_type = raw_line[len("event: "):].strip()
elif raw_line.startswith("data: "):
payload_str = raw_line[len("data: "):]
try:
data = json.loads(payload_str)
except json.JSONDecodeError:
continue
if event_type == "chunk":
tokens.append(data.get("chunk", ""))
elif event_type == "tool_call":
tool_calls.append(data.get("tool_call", data))
elif event_type == "done":
break
event_type = None # reset after consuming data line
break # stream completed successfully
except FableAPIError as exc:
if exc.status_code == 404 and attempt < 9:
await asyncio.sleep(0.3)
continue
raise
return {
"conversation_id": conversation_id,
+2 -2
View File
@@ -87,7 +87,7 @@ async def add_task_log(
client: FableClient,
*,
task_id: int,
body: str,
content: str,
) -> dict[str, Any]:
"""Append a log entry to a task."""
return await client.post(f"/api/tasks/{task_id}/logs", json={"body": body})
return await client.post(f"/api/tasks/{task_id}/logs", json={"content": content})
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "fable-mcp"
version = "0.1.0"
version = "0.2.4"
description = "MCP server for Fabled Assistant"
requires-python = ">=3.12"
dependencies = [
+3 -3
View File
@@ -46,9 +46,9 @@ async def test_update_task_patches(client):
@pytest.mark.asyncio
async def test_add_task_log_posts_to_logs_endpoint(client):
from fable_mcp.tools.tasks import add_task_log
client.post = AsyncMock(return_value={"id": 1, "body": "progress"})
await add_task_log(client, task_id=9, body="progress")
client.post = AsyncMock(return_value={"id": 1, "content": "progress"})
await add_task_log(client, task_id=9, content="progress")
client.post.assert_called_once()
path = client.post.call_args[0][0]
assert path == "/api/tasks/9/logs"
assert client.post.call_args[1]["json"]["body"] == "progress"
assert client.post.call_args[1]["json"]["content"] == "progress"
+14 -1
View File
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import VoiceOverlay from "@/components/VoiceOverlay.vue";
import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
@@ -22,6 +23,7 @@ const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
function startAppServices() {
chatStore.startStatusPolling();
settingsStore.fetchSettings();
settingsStore.checkVoiceStatus();
// Sync browser timezone to the server on every login/page load.
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
}
@@ -119,6 +121,10 @@ function onGlobalKeydown(e: KeyboardEvent) {
router.push("/chat");
}
break;
case " ":
e.preventDefault();
document.dispatchEvent(new CustomEvent("voice:ptt-toggle"));
break;
}
}
@@ -268,6 +274,10 @@ onUnmounted(() => {
<kbd class="shortcut-key">Enter</kbd>
<span class="shortcut-desc">New line</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Space</kbd>
<span class="shortcut-desc">Tap to speak (voice, when enabled)</span>
</div>
</div>
</div>
</div>
@@ -277,6 +287,7 @@ onUnmounted(() => {
<template v-else>
<router-view />
</template>
<VoiceOverlay />
<ToastNotification />
</template>
@@ -313,7 +324,9 @@ onUnmounted(() => {
min-height: 0;
overflow-y: auto;
}
.app-content:has(.workspace-root) {
.app-content:has(.workspace-root),
.app-content:has(.chat-page),
.app-content:has(.knowledge-root) {
overflow: hidden;
}
+91 -1
View File
@@ -123,7 +123,6 @@ export interface NotificationEntry {
export interface UserSearchResult {
id: number
username: string
email: string | null
}
// --- User search ---
@@ -427,6 +426,10 @@ export async function deleteRssReaction(rssItemId: number): Promise<void> {
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
}
export async function openArticleInChat(itemId: number): Promise<{ conversation_id: number }> {
return apiPost(`/api/chat/from-article/${itemId}`, {});
}
export async function 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 });
@@ -620,3 +623,90 @@ export function getNewsItems(params: GetNewsItemsParams = {}) {
`/api/briefing/news?${p}`
)
}
// ─── Voice ────────────────────────────────────────────────────────────────────
export interface VoiceStatusResult {
enabled: boolean
stt: boolean
tts: boolean
stt_model?: string
tts_backend?: string
}
export interface VoiceEntry {
id: string
label: string
}
export interface VoiceBlendEntry {
voice: string
weight: number
}
export const getVoiceStatus = () => apiGet<VoiceStatusResult>('/api/voice/status')
export const getVoiceList = () =>
apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices)
export async function transcribeAudio(blob: Blob): Promise<{ transcript: string; duration_ms: number }> {
const form = new FormData()
form.append('audio', blob, 'audio.webm')
const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new ApiError(res.status, err)
}
return res.json()
}
export async function synthesiseSpeech(
text: string,
voice?: string,
speed?: number,
voiceBlend?: VoiceBlendEntry[]
): Promise<Blob> {
// Only send voice/speed/blend when explicitly provided — omitting them lets
// the server auto-load the user's saved voice settings (voice, speed, blend).
const body: Record<string, unknown> = { text }
if (voiceBlend && voiceBlend.length >= 2) {
body.voice_blend = voiceBlend
if (speed !== undefined) body.speed = speed
} else if (voice !== undefined || speed !== undefined) {
body.voice = voice ?? 'af_heart'
body.speed = speed ?? 1.0
}
const res = await fetch('/api/voice/synthesise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new ApiError(res.status, err)
}
return res.blob()
}
// ── User Profile ─────────────────────────────────────────────────────────────
export interface UserProfile {
display_name: string
job_title: string
industry: string
expertise_level: 'novice' | 'intermediate' | 'expert'
response_style: 'concise' | 'balanced' | 'detailed'
tone: 'casual' | 'professional' | 'technical'
interests: string[]
work_schedule: { days?: string[]; start?: string; end?: string }
learned_summary: string
observations_count: number
observations_updated_at: string | null
}
export const getProfile = () => apiGet<UserProfile>('/api/profile')
export const updateProfile = (data: Partial<UserProfile>) =>
apiPut<UserProfile>('/api/profile', data)
export const consolidateProfile = () =>
apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {})
export const clearProfileObservations = () => apiDelete('/api/profile/observations')
+28
View File
@@ -180,6 +180,34 @@
color: var(--color-text-muted);
}
/* Interactive checkboxes — marked output in the list-note viewer */
.prose--checklist ul {
list-style: none;
padding-left: 0.25rem;
}
.prose--checklist li {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.prose--checklist li input[type="checkbox"] {
flex-shrink: 0;
accent-color: var(--color-primary);
cursor: pointer;
width: 0.95em;
height: 0.95em;
margin: 0;
}
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
.prose--checklist li:has(input[type="checkbox"]:checked) {
text-decoration: line-through;
color: var(--color-text-muted);
}
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
text-decoration: none; /* don't strike through the checkbox itself */
}
/* Tiptap editor */
.tiptap-editor .ProseMirror {
outline: none;
+6
View File
@@ -4,6 +4,7 @@
--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;
@@ -49,12 +50,17 @@
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
/* Layout */
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
}
[data-theme="dark"] {
--color-bg: #111113;
--color-bg-secondary: #18181f;
--color-bg-card: #1e1e27;
--color-surface: #1a1b22;
--color-text: #e4e4f0;
--color-text-secondary: #8888a8;
--color-text-muted: #52526a;
+10 -12
View File
@@ -15,7 +15,8 @@ const chatStore = useChatStore();
const router = useRouter();
const route = useRoute();
const isChatActive = computed(() => route.path.startsWith("/chat"));
const isChatActive = computed(() => route.path.startsWith("/chat"))
const isKnowledgeActive = computed(() => route.path === "/" || route.path === "/knowledge");
const mobileMenuOpen = ref(false);
@@ -72,15 +73,13 @@ router.afterEach(() => {
<!-- Center: primary navigation (desktop) -->
<div class="nav-center">
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/" 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="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
<!-- Right: status + utilities + gear + user -->
@@ -123,13 +122,12 @@ router.afterEach(() => {
<!-- Mobile dropdown -->
<div v-if="mobileMenuOpen" class="mobile-menu">
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/" 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="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
+397
View File
@@ -0,0 +1,397 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { apiGet, transcribeAudio } from '@/api/client'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import type { Note } from '@/types/note'
const props = withDefaults(defineProps<{
/** Textarea placeholder */
placeholder?: string
/** When true, hides the note picker (briefing mode) */
briefingMode?: boolean
/** Pill shape — compact rounded style for widget */
pill?: boolean
}>(), {
placeholder: 'Type a message… (Enter to send, Shift+Enter for new line)',
briefingMode: false,
pill: false,
})
const emit = defineEmits<{
submit: [payload: { content: string; contextNoteId?: number }]
abort: []
}>()
const store = useChatStore()
const settingsStore = useSettingsStore()
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
// ── Core input ────────────────────────────────────────────────────────────────
const messageInput = ref('')
const inputEl = ref<HTMLTextAreaElement | null>(null)
const wrapperEl = ref<HTMLElement | null>(null)
function autoResize() {
const el = inputEl.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
}
function resetTextareaHeight() {
const el = inputEl.value
if (!el) return
el.style.height = 'auto'
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSubmit()
}
}
function onSubmit() {
const content = messageInput.value.trim()
if (!content) return
emit('submit', { content, contextNoteId: attachedNote.value?.id })
messageInput.value = ''
attachedNote.value = null
resetTextareaHeight()
}
// ── Note picker ───────────────────────────────────────────────────────────────
const attachedNote = ref<{ id: number; title: string } | null>(null)
const showNotePicker = ref(false)
const noteSearchQuery = ref('')
const noteSearchResults = ref<{ id: number; title: string }[]>([])
const noteSearchLoading = ref(false)
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null
function toggleNotePicker() {
showNotePicker.value = !showNotePicker.value
if (showNotePicker.value) {
noteSearchQuery.value = ''
noteSearchResults.value = []
nextTick(() => {
(wrapperEl.value?.querySelector('.note-picker-search') as HTMLInputElement)?.focus()
})
}
}
function onNoteSearchInput() {
if (noteSearchTimer) clearTimeout(noteSearchTimer)
noteSearchTimer = setTimeout(async () => {
const q = noteSearchQuery.value.trim()
if (!q) { noteSearchResults.value = []; return }
noteSearchLoading.value = true
try {
const data = await apiGet<{ notes: Note[] }>(`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`)
noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title }))
} catch {
noteSearchResults.value = []
} finally {
noteSearchLoading.value = false
}
}, 250)
}
function selectNote(note: { id: number; title: string }) {
attachedNote.value = note
showNotePicker.value = false
}
function removeAttachedNote() {
attachedNote.value = null
}
// ── PTT ───────────────────────────────────────────────────────────────────────
const transcribingVoice = ref(false)
const recorder = useVoiceRecorder()
async function startPtt() {
if (!voiceEnabled.value || recorder.recording.value) return
await recorder.startRecording()
}
async function stopPtt() {
if (!recorder.recording.value) return
transcribingVoice.value = true
try {
const blob = await recorder.stopRecording()
const { transcript } = await transcribeAudio(blob)
if (transcript.trim()) {
messageInput.value = transcript.trim()
await nextTick()
autoResize()
onSubmit()
}
} catch { /* transcription failed silently */ }
finally { transcribingVoice.value = false }
}
// ── Exposed interface ─────────────────────────────────────────────────────────
function focus() {
inputEl.value?.focus()
}
function prefill(text: string) {
messageInput.value = text
nextTick(() => {
autoResize()
inputEl.value?.focus()
})
}
defineExpose({ focus, prefill })
</script>
<template>
<div ref="wrapperEl" class="chat-input-bar" :class="{ 'chat-input-bar--pill': pill }">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" aria-label="Remove" @click="removeAttachedNote">&times;</button>
</span>
</div>
<div class="input-row">
<!-- Note picker -->
<div class="note-picker-wrapper">
<button
class="btn-icon"
@click="toggleNotePicker"
: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>
</button>
<div v-if="showNotePicker" class="note-picker-dropdown">
<input
class="note-picker-search"
v-model="noteSearchQuery"
@input="onNoteSearchInput"
placeholder="Search notes..."
/>
<div class="note-picker-results">
<div
v-for="note in noteSearchResults"
:key="note.id"
class="note-picker-item"
@click="selectNote(note)"
>{{ note.title || 'Untitled' }}</div>
<div v-if="noteSearchLoading" class="note-picker-empty">Searching...</div>
<div v-else-if="noteSearchQuery && !noteSearchResults.length" class="note-picker-empty">No notes found</div>
</div>
</div>
</div>
<!-- Textarea -->
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="!store.chatReady ? 'Chat unavailable' : store.streaming ? 'Type to queue… (Enter to queue)' : placeholder"
:disabled="!store.chatReady"
rows="1"
class="input-textarea"
></textarea>
<!-- PTT mic -->
<button
v-if="voiceEnabled && recorder.isSupported"
class="btn-icon btn-mic"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
@mousedown.prevent="startPtt"
@mouseup.prevent="stopPtt"
@touchstart.prevent="startPtt"
@touchend.prevent="stopPtt"
:disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Release to send' : 'Hold to speak'"
aria-label="Push to talk"
>
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
</svg>
</button>
<!-- Abort (streaming) or Send -->
<button
v-if="store.streaming"
class="btn-abort-inline"
@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>
</button>
<button
v-else
class="btn-send"
@click="onSubmit"
:disabled="!messageInput.trim() || !store.chatReady"
>&uarr;</button>
</div>
</div>
</template>
<style scoped>
.chat-input-bar {
width: 100%;
}
.attached-note {
padding: 0.25rem 0.5rem;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.2rem;
background: var(--color-primary);
color: #fff;
border-radius: 10px;
padding: 0.15rem 0.4rem;
font-size: 0.75rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 0 0.1rem;
}
.attached-note-remove:hover { color: #fff; }
.input-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
background: var(--color-input-bar-bg);
border-radius: 12px;
box-shadow: 0 2px 8px var(--color-shadow);
}
.chat-input-bar--pill .input-row {
border-radius: 24px;
padding: 0.6rem 0.6rem 0.6rem 1rem;
}
.input-textarea {
flex: 1;
resize: none;
padding: 0.35rem 0.5rem;
border: none;
background: transparent;
color: var(--color-input-bar-text);
outline: none;
font-family: inherit;
font-size: 0.9rem;
max-height: 150px;
overflow-y: auto;
}
.input-textarea::placeholder { color: var(--color-input-bar-placeholder); }
.input-textarea:disabled { opacity: 0.5; }
.btn-icon {
background: none;
border: none;
cursor: pointer;
color: var(--color-input-bar-text);
opacity: 0.6;
padding: 0.2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.btn-icon:hover { opacity: 1; }
.btn-icon:disabled { opacity: 0.3; cursor: default; }
.btn-mic.mic-recording { opacity: 1; color: #ef4444; }
.btn-mic.mic-transcribing { opacity: 0.5; }
.note-picker-wrapper { position: relative; }
.note-picker-dropdown {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 260px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 20;
overflow: hidden;
}
.note-picker-search {
width: 100%;
padding: 0.45rem 0.65rem;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.note-picker-results { max-height: 180px; overflow-y: auto; }
.note-picker-item {
padding: 0.4rem 0.65rem;
cursor: pointer;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-picker-item:hover { background: var(--color-bg-secondary); }
.note-picker-empty { padding: 0.4rem 0.65rem; color: var(--color-text-muted); font-size: 0.8rem; }
.btn-send {
width: 30px;
min-width: 30px;
height: 30px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 1rem;
flex-shrink: 0;
transition: box-shadow 0.15s;
}
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
.btn-abort-inline {
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 50%;
cursor: pointer;
flex-shrink: 0;
}
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { renderMarkdown } from '@/utils/markdown'
import ToolCallCard from '@/components/ToolCallCard.vue'
import ToolConfirmCard from '@/components/ToolConfirmCard.vue'
const store = useChatStore()
const settingsStore = useSettingsStore()
const streamingRendered = computed(() => {
if (!store.streamingContent) return ''
return renderMarkdown(store.streamingContent)
})
</script>
<template>
<div class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
<ToolCallCard
v-for="(tc, i) in store.streamingToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<ToolConfirmCard
v-if="store.streamingPendingTool"
:pending-tool="store.streamingPendingTool"
@accept="store.confirmTool(true)"
@decline="store.confirmTool(false)"
/>
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
</div>
<details
v-if="store.streamingThinking"
class="thinking-block"
:open="!store.streamingContent"
>
<summary class="thinking-summary">Reasoning</summary>
<pre class="thinking-text">{{ store.streamingThinking }}</pre>
</details>
<div class="message-content prose" v-html="streamingRendered"></div>
<span
v-if="!store.streamingStatus && !store.streamingThinking && !store.streamingContent"
class="typing-indicator"
></span>
</div>
</div>
</template>
@@ -1,351 +0,0 @@
<script setup lang="ts">
import { ref, nextTick } from "vue";
import { apiGet } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import type { Note } from "@/types/note";
const emit = defineEmits<{
submit: [payload: { content: string; contextNoteId?: number }];
}>();
const store = useChatStore();
const messageInput = ref("");
const inputEl = ref<HTMLTextAreaElement | null>(null);
// Note picker state
const attachedNote = ref<{ id: number; title: string } | null>(null);
const showNotePicker = ref(false);
const noteSearchQuery = ref("");
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
const noteSearchLoading = ref(false);
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
function onSubmit() {
const content = messageInput.value.trim();
if (!content) return;
emit("submit", {
content,
contextNoteId: attachedNote.value?.id,
});
messageInput.value = "";
attachedNote.value = null;
resetTextareaHeight();
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}
function autoResize() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
function resetTextareaHeight() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
}
// Note picker
function toggleNotePicker() {
showNotePicker.value = !showNotePicker.value;
if (showNotePicker.value) {
noteSearchQuery.value = "";
noteSearchResults.value = [];
nextTick(() => {
const el = document.querySelector(
".dashboard-chat .note-picker-search"
) as HTMLInputElement;
el?.focus();
});
}
}
function onNoteSearchInput() {
if (noteSearchTimer) clearTimeout(noteSearchTimer);
noteSearchTimer = setTimeout(async () => {
const q = noteSearchQuery.value.trim();
if (!q) {
noteSearchResults.value = [];
return;
}
noteSearchLoading.value = true;
try {
const data = await apiGet<{ notes: Note[] }>(
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
);
noteSearchResults.value = data.notes.map((n) => ({
id: n.id,
title: n.title,
}));
} catch {
noteSearchResults.value = [];
} finally {
noteSearchLoading.value = false;
}
}, 250);
}
function selectNote(note: { id: number; title: string }) {
attachedNote.value = note;
showNotePicker.value = false;
}
function removeAttachedNote() {
attachedNote.value = null;
}
function focus() {
inputEl.value?.focus();
}
defineExpose({ focus });
</script>
<template>
<div class="dashboard-chat">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">
&times;
</button>
</span>
</div>
<div class="chat-input-bar">
<div class="note-picker-wrapper">
<button
class="btn-attach"
@click="toggleNotePicker"
: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>
</button>
<div v-if="showNotePicker" class="note-picker-dropdown">
<input
class="note-picker-search"
v-model="noteSearchQuery"
@input="onNoteSearchInput"
placeholder="Search notes..."
/>
<div class="note-picker-results">
<div
v-for="note in noteSearchResults"
:key="note.id"
class="note-picker-item"
@click="selectNote(note)"
>
{{ note.title || "Untitled" }}
</div>
<div v-if="noteSearchLoading" class="note-picker-empty">
Searching...
</div>
<div
v-else-if="noteSearchQuery && !noteSearchResults.length"
class="note-picker-empty"
>
No notes found
</div>
</div>
</div>
</div>
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="
!store.chatReady ? 'Chat unavailable'
: store.streaming ? 'Type to queue… (Enter to queue)'
: 'Start a new chat… (Enter to send)'
"
:disabled="!store.chatReady"
rows="1"
></textarea>
<button
class="btn-send"
@click="onSubmit"
:disabled="!messageInput.trim() || !store.chatReady"
>
&uarr;
</button>
</div>
</div>
</template>
<style scoped>
.dashboard-chat {
margin-top: 0.75rem;
}
.attached-note {
padding: 0.25rem 0;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 12px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 0.15rem;
}
.attached-note-remove:hover {
color: #fff;
}
.chat-input-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
background: var(--color-input-bar-bg);
border-radius: 20px;
box-shadow: 0 2px 12px var(--color-shadow);
}
.chat-input-bar textarea {
flex: 1;
resize: none;
padding: 0.4rem 0.5rem;
border: none;
border-radius: 12px;
font-family: inherit;
font-size: 0.95rem;
background: transparent;
color: var(--color-input-bar-text);
outline: none;
max-height: 120px;
overflow-y: auto;
}
.chat-input-bar textarea::placeholder {
color: var(--color-input-bar-placeholder);
}
.chat-input-bar textarea:disabled {
opacity: 0.5;
}
/* Note picker */
.note-picker-wrapper {
position: relative;
}
.btn-attach {
background: none;
border: none;
cursor: pointer;
color: var(--color-input-bar-text);
opacity: 0.6;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.btn-attach:hover {
opacity: 1;
}
.btn-attach:disabled {
opacity: 0.3;
cursor: default;
}
.note-picker-dropdown {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 280px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 10;
overflow: hidden;
}
.note-picker-search {
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.note-picker-results {
max-height: 200px;
overflow-y: auto;
}
.note-picker-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-picker-item:hover {
background: var(--color-bg-secondary);
}
.note-picker-empty {
padding: 0.5rem 0.75rem;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.btn-send {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 1.1rem;
flex-shrink: 0;
}
.btn-send:disabled {
opacity: 0.35;
cursor: default;
}
</style>
+4 -2
View File
@@ -68,9 +68,11 @@ function resetForm() {
if (props.event) {
title.value = props.event.title;
allDay.value = props.event.all_day;
startDate.value = dateFromIso(props.event.start_dt);
// All-day events: use UTC date string directly to avoid timezone shifting
// (UTC midnight parsed through new Date() becomes the previous day in UTC-X zones)
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
endDate.value = props.event.end_dt ? dateFromIso(props.event.end_dt) : "";
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : "";
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : "";
description.value = props.event.description || "";
location.value = props.event.location || "";
-1
View File
@@ -138,7 +138,6 @@ onMounted(async () => {
<ul v-if="userResults.length" class="user-results">
<li v-for="u in userResults" :key="u.id" @click="selectUser(u)" class="user-result-item">
<span class="user-result-name">{{ u.username }}</span>
<span class="user-result-email">{{ u.email }}</span>
</li>
</ul>
</div>
+7 -4
View File
@@ -270,8 +270,11 @@ async function openEventSlideOver(id: number | undefined) {
}
}
function closeEventSlideOver() {
function closeEventSlideOver(changed = false) {
eventSlideOverOpen.value = false;
if (changed) {
document.dispatchEvent(new Event("fable:calendar-changed"));
}
}
</script>
@@ -522,9 +525,9 @@ function closeEventSlideOver() {
:event="eventSlideOverEntry"
initial-date=""
@close="closeEventSlideOver"
@created="closeEventSlideOver"
@updated="closeEventSlideOver"
@deleted="closeEventSlideOver"
@created="() => closeEventSlideOver(true)"
@updated="() => closeEventSlideOver(true)"
@deleted="() => closeEventSlideOver(true)"
/>
</template>
+568
View File
@@ -0,0 +1,568 @@
<script setup lang="ts">
/**
* VoiceOverlay — global floating push-to-talk button.
*
* Full flow: record → transcribe → send to voice conv → stream → TTS → play.
* Manages its own "voice" conversation; does NOT touch the chat store so it
* never disrupts an open chat session.
*
* Space bar toggles PTT when no input field is focused (wired from App.vue via
* the "voice:ptt-toggle" custom event).
*/
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useVoiceAudio } from '@/composables/useVoiceAudio'
import { useSilenceDetector } from '@/composables/useSilenceDetector'
import { apiPost, apiSSEStream, getVoiceStatus, transcribeAudio, synthesiseSpeech } from '@/api/client'
// ─── Voice service availability ──────────────────────────────────────────────
const voiceEnabled = ref(false)
async function checkVoice() {
try {
const s = await getVoiceStatus()
voiceEnabled.value = s.enabled && s.stt && s.tts
} catch { /* feature absent */ }
}
// ─── Conversation management ─────────────────────────────────────────────────
const STORAGE_KEY = 'voice_overlay_conv_id'
const convId = ref<number | null>(Number(localStorage.getItem(STORAGE_KEY)) || null)
interface VoiceMessage { role: 'user' | 'assistant'; content: string }
const messages = ref<VoiceMessage[]>([])
async function ensureConversation(): Promise<number> {
if (convId.value) {
// Verify it still exists
try {
await fetch(`/api/chat/conversations/${convId.value}`)
.then((r) => { if (!r.ok) throw new Error('gone') })
return convId.value
} catch {
convId.value = null
localStorage.removeItem(STORAGE_KEY)
}
}
const conv = await apiPost<{ id: number }>('/api/chat/conversations', {
title: 'Voice',
conversation_type: 'voice',
})
convId.value = conv.id
localStorage.setItem(STORAGE_KEY, String(conv.id))
return conv.id
}
// ─── State machine ────────────────────────────────────────────────────────────
type Phase = 'idle' | 'recording' | 'transcribing' | 'generating' | 'speaking' | 'error'
const phase = ref<Phase>('idle')
const errorMsg = ref('')
const streamContent = ref('')
const open = ref(false)
const isBusy = computed(() => phase.value !== 'idle' && phase.value !== 'error')
// ─── Composables ─────────────────────────────────────────────────────────────
const recorder = useVoiceRecorder()
const audio = useVoiceAudio()
const silenceDetector = useSilenceDetector()
// ─── Core PTT flow ────────────────────────────────────────────────────────────
async function startPtt() {
if (!voiceEnabled.value || isBusy.value) return
// Stop any in-progress TTS playback before opening the mic
audio.stop()
errorMsg.value = ''
open.value = true
await recorder.startRecording()
if (recorder.error.value) {
phase.value = 'error'
errorMsg.value = recorder.error.value
return
}
phase.value = 'recording'
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, stopPtt)
}
}
async function stopPtt() {
silenceDetector.stop()
if (phase.value !== 'recording') return
phase.value = 'transcribing'
let blob: Blob
try {
blob = await recorder.stopRecording()
} catch {
phase.value = 'error'
errorMsg.value = 'Recording failed'
return
}
let transcript: string
try {
const result = await transcribeAudio(blob)
transcript = result.transcript.trim()
} catch {
phase.value = 'error'
errorMsg.value = 'Transcription failed'
return
}
if (!transcript) {
phase.value = 'idle'
return
}
messages.value.push({ role: 'user', content: transcript })
scrollToBottom()
// Send to voice conversation
phase.value = 'generating'
streamContent.value = ''
let cid: number
try {
cid = await ensureConversation()
} catch {
phase.value = 'error'
errorMsg.value = 'Could not create voice conversation'
return
}
let assistantMessageId: number
try {
const resp = await apiPost<{ assistant_message_id: number }>(
`/api/chat/conversations/${cid}/messages`,
{
content: transcript,
user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
)
assistantMessageId = resp.assistant_message_id
} catch {
phase.value = 'error'
errorMsg.value = 'Failed to send message'
return
}
// Stream the response
await new Promise<void>((resolve) => {
const handle = apiSSEStream(
`/api/chat/conversations/${cid}/generation/stream`,
(event) => {
switch (event.event) {
case 'chunk':
streamContent.value += event.data.chunk as string
break
case 'done':
handle.close()
resolve()
break
case 'error':
handle.close()
resolve()
break
}
}
)
// Safety timeout: 3 minutes
setTimeout(() => { handle.close(); resolve() }, 180_000)
})
const responseText = streamContent.value.trim()
if (responseText) {
messages.value.push({ role: 'assistant', content: responseText })
streamContent.value = ''
scrollToBottom()
}
// Synthesise and play
if (responseText) {
phase.value = 'speaking'
try {
const wavBlob = await synthesiseSpeech(responseText)
await audio.play(wavBlob)
} catch {
// TTS failure is non-critical; show response text
}
}
phase.value = 'idle'
assistantMessageId // consumed; suppress lint
}
function onBtnClick() {
if (phase.value === 'error') { phase.value = 'idle'; return }
if (phase.value === 'recording') { stopPtt(); return }
if (phase.value === 'idle') { startPtt() }
}
function cancelAll() {
silenceDetector.stop()
recorder.stopRecording().catch(() => {})
audio.stop()
phase.value = 'idle'
streamContent.value = ''
errorMsg.value = ''
}
// ─── Space bar PTT (event from App.vue) ──────────────────────────────────────
function onPttToggle() {
if (!voiceEnabled.value) return
if (phase.value === 'recording') {
stopPtt()
} else if (phase.value === 'idle' || phase.value === 'error') {
startPtt()
}
}
// ─── Scroll ───────────────────────────────────────────────────────────────────
const transcriptEl = ref<HTMLElement | null>(null)
function scrollToBottom() {
setTimeout(() => {
if (transcriptEl.value) {
transcriptEl.value.scrollTop = transcriptEl.value.scrollHeight
}
}, 50)
}
// ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(() => {
checkVoice()
document.addEventListener('voice:ptt-toggle', onPttToggle)
})
onUnmounted(() => {
document.removeEventListener('voice:ptt-toggle', onPttToggle)
cancelAll()
})
</script>
<template>
<Teleport to="body">
<div v-if="voiceEnabled" class="voice-overlay">
<!-- Expanded transcript panel -->
<Transition name="panel-slide">
<div v-if="open && messages.length > 0" class="voice-panel">
<div class="voice-panel-header">
<span class="voice-panel-title">Voice</span>
<button class="voice-panel-close" @click="open = false" aria-label="Close">×</button>
</div>
<div class="voice-transcript" ref="transcriptEl">
<div
v-for="(msg, i) in messages.slice(-10)"
:key="i"
:class="['voice-msg', `voice-msg--${msg.role}`]"
>{{ msg.content }}</div>
<!-- Live streaming text -->
<div v-if="phase === 'generating' && streamContent" class="voice-msg voice-msg--assistant voice-msg--streaming">
{{ streamContent }}<span class="voice-cursor"></span>
</div>
</div>
</div>
</Transition>
<!-- PTT button -->
<div class="voice-btn-wrap">
<!-- Status label -->
<div v-if="phase !== 'idle'" class="voice-status-label">
<span v-if="phase === 'recording'">Recording</span>
<span v-else-if="phase === 'transcribing'">Transcribing</span>
<span v-else-if="phase === 'generating'">Thinking</span>
<span v-else-if="phase === 'speaking'">Speaking</span>
<span v-else-if="phase === 'error'" class="voice-error-label">{{ errorMsg || 'Error' }}</span>
</div>
<div v-else-if="!open || !messages.length" class="voice-status-label voice-hint">
Tap or press <kbd>Space</kbd>
</div>
<!-- Cancel button (shown while busy or speaking) -->
<button
v-if="isBusy || audio.playing.value"
class="voice-cancel"
@click="cancelAll"
aria-label="Cancel"
title="Cancel"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
<!-- Main PTT button -->
<button
class="voice-ptt-btn"
:class="{
'voice-ptt--recording': phase === 'recording',
'voice-ptt--busy': phase === 'generating' || phase === 'transcribing',
'voice-ptt--speaking': phase === 'speaking' || audio.playing.value,
'voice-ptt--error': phase === 'error',
}"
@click.prevent="onBtnClick"
:disabled="phase === 'transcribing' || phase === 'generating'"
:aria-label="phase === 'recording' ? 'Click to stop' : 'Click to speak'"
:title="phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'"
>
<!-- Idle: mic icon -->
<svg v-if="phase === 'idle' || phase === 'error'" width="22" height="22" 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>
<!-- Recording: amplitude bars -->
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
<span
v-for="n in 3"
:key="n"
class="voice-amp-bar"
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
></span>
</span>
<!-- Busy: spinner dots -->
<span v-else-if="phase === 'transcribing' || phase === 'generating'" class="voice-spinner">
<span></span><span></span><span></span>
</span>
<!-- Speaking: sound waves -->
<svg v-else-if="phase === 'speaking'" width="22" height="22" 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>
</button>
</div>
</div>
</Teleport>
</template>
<style scoped>
.voice-overlay {
position: fixed;
bottom: 2rem;
right: 1.5rem;
z-index: 8000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
pointer-events: none;
}
/* ─── Panel ──────────────────────────────────────────────────────────────── */
.voice-panel {
pointer-events: all;
width: min(320px, 90vw);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 14px;
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.22));
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 340px;
}
.voice-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.85rem 0.5rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.voice-panel-title {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-primary);
}
.voice-panel-close {
background: none;
border: none;
font-size: 1.3rem;
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
padding: 0 0.1rem;
}
.voice-panel-close:hover { color: var(--color-text); }
.voice-transcript {
flex: 1;
overflow-y: auto;
padding: 0.65rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.voice-msg {
font-size: 0.875rem;
line-height: 1.45;
padding: 0.4rem 0.65rem;
border-radius: 10px;
max-width: 88%;
white-space: pre-wrap;
word-break: break-word;
}
.voice-msg--user {
align-self: flex-end;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
color: var(--color-text);
}
.voice-msg--assistant {
align-self: flex-start;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.voice-msg--streaming { opacity: 0.85; }
.voice-cursor {
display: inline-block;
animation: blink 0.9s step-end infinite;
margin-left: 1px;
color: var(--color-primary);
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
/* ─── Button cluster ─────────────────────────────────────────────────────── */
.voice-btn-wrap {
pointer-events: all;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.35rem;
}
.voice-status-label {
font-size: 0.72rem;
color: var(--color-text-muted);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.18rem 0.5rem;
white-space: nowrap;
box-shadow: 0 2px 6px var(--color-shadow, rgba(0,0,0,0.12));
}
.voice-hint { opacity: 0.7; }
.voice-hint kbd {
font-family: ui-monospace, monospace;
font-size: 0.68rem;
padding: 0.05rem 0.25rem;
border: 1px solid var(--color-border);
border-radius: 3px;
background: var(--color-bg-secondary);
}
.voice-error-label { color: #ef4444; }
.voice-cancel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-text-muted);
box-shadow: 0 2px 6px var(--color-shadow, rgba(0,0,0,0.12));
transition: all 0.15s;
}
.voice-cancel:hover { color: #ef4444; border-color: #ef4444; }
.voice-ptt-btn {
width: 58px;
height: 58px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.45);
transition: transform 0.12s, box-shadow 0.12s, background 0.2s;
touch-action: none;
user-select: none;
flex-shrink: 0;
}
.voice-ptt-btn:hover:not(:disabled) {
transform: scale(1.06);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.55);
}
.voice-ptt-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.voice-ptt--recording {
background: linear-gradient(135deg, #ef4444, #dc2626) !important;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.5) !important;
animation: ptt-pulse 0.9s ease-in-out infinite;
}
.voice-ptt--busy {
background: linear-gradient(135deg, #8b5cf6, #7c3aed) !important;
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.45) !important;
}
.voice-ptt--speaking {
background: linear-gradient(135deg, #10b981, #059669) !important;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.45) !important;
animation: ptt-pulse 1.4s ease-in-out infinite;
}
.voice-ptt--error {
background: linear-gradient(135deg, #6b7280, #4b5563) !important;
box-shadow: none !important;
}
@keyframes ptt-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
/* ─── Spinner dots ───────────────────────────────────────────────────────── */
.voice-spinner {
display: flex;
gap: 4px;
align-items: center;
}
.voice-spinner span {
width: 5px;
height: 5px;
border-radius: 50%;
background: #fff;
animation: dot-bounce 1.2s ease-in-out infinite;
}
.voice-spinner span:nth-child(2) { animation-delay: 0.2s; }
.voice-spinner span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
/* ─── Amplitude bars (recording state) ─────────────────────────────────── */
.voice-amp-bars {
display: flex;
gap: 3px;
align-items: center;
height: 22px;
}
.voice-amp-bar {
width: 4px;
height: 18px;
background: #fff;
border-radius: 2px;
transform-origin: center;
transition: transform 0.08s ease;
}
/* ─── Transition ───────────────────────────────────────────────────────── */
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.panel-slide-enter-from,
.panel-slide-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
+55 -1
View File
@@ -6,6 +6,9 @@ interface ForecastDay {
condition: string
high: number
low: number
precip_probability: number | null
precip_mm: number | null
windspeed_max: number
}
interface WeatherData {
@@ -17,6 +20,7 @@ interface WeatherData {
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
wind_unit?: string
forecast: ForecastDay[]
}
@@ -25,6 +29,23 @@ const props = defineProps<{
tempUnit?: string
}>()
function weatherIcon(condition: string): string {
const c = condition.toLowerCase()
if (c.includes('thunderstorm')) return '⛈️'
if (c.includes('hail')) return '🌨️'
if (c.includes('snow showers')) return '🌨️'
if (c.includes('snow')) return '❄️'
if (c.includes('rain showers: violent')) return '⛈️'
if (c.includes('rain showers')) return '🌦️'
if (c.includes('drizzle') || c.includes('rain')) return '🌧️'
if (c.includes('fog')) return '🌫️'
if (c.includes('overcast')) return '☁️'
if (c.includes('partly cloudy')) return '⛅'
if (c.includes('mainly clear')) return '🌤️'
if (c.includes('clear')) return '☀️'
return '🌡️'
}
const unit = computed(() => props.tempUnit ?? 'C')
const tempDelta = computed(() => {
@@ -56,6 +77,7 @@ const fetchedAtLabel = computed(() => {
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
</div>
<div class="weather-current">
<span class="weather-icon">{{ weatherIcon(weather.condition) }}</span>
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
<span class="weather-condition">{{ weather.condition }}</span>
</div>
@@ -66,8 +88,17 @@ const fetchedAtLabel = computed(() => {
<div class="weather-forecast" v-if="weather.forecast.length">
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
<span class="forecast-day-name">{{ day.day }}</span>
<span class="forecast-icon">{{ weatherIcon(day.condition) }}</span>
<span class="forecast-condition">{{ day.condition }}</span>
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
<span v-if="day.precip_probability != null && day.precip_probability > 0" class="forecast-precip">
💧 {{ day.precip_probability }}%
</span>
<span v-else-if="day.precip_mm != null && day.precip_mm > 0" class="forecast-precip">
💧 {{ day.precip_mm.toFixed(1) }}mm
</span>
<span v-else class="forecast-precip forecast-precip--dry">💧 </span>
<span class="forecast-wind">💨 {{ day.windspeed_max }} {{ weather.wind_unit ?? 'km/h' }}</span>
</div>
</div>
</div>
@@ -110,6 +141,11 @@ const fetchedAtLabel = computed(() => {
margin-bottom: 0.5rem;
}
.weather-icon {
font-size: 2rem;
line-height: 1;
}
.weather-temp {
font-size: 2rem;
font-weight: 700;
@@ -153,16 +189,34 @@ const fetchedAtLabel = computed(() => {
font-weight: 600;
}
.forecast-icon {
font-size: 1.2rem;
line-height: 1;
}
.forecast-condition {
font-size: 0.7rem;
color: var(--color-text-muted);
font-size: 0.75rem;
text-align: center;
line-height: 1.2;
word-break: break-word;
}
.forecast-temps {
white-space: nowrap;
}
.forecast-precip,
.forecast-wind {
font-size: 0.72rem;
white-space: nowrap;
color: var(--color-text-muted);
}
.forecast-precip--dry {
opacity: 0.35;
}
.weather-unavailable {
color: var(--color-text-muted);
font-style: italic;
@@ -293,6 +293,8 @@ onMounted(async () => {
});
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
defineExpose({ reload: loadProjectNotes });
</script>
<template>
+14
View File
@@ -0,0 +1,14 @@
import { ref, watch } from 'vue'
const LISTEN_MODE_KEY = 'fa_listen_mode'
// Shared across all views — persisted to localStorage
const _listenMode = ref<boolean>(localStorage.getItem(LISTEN_MODE_KEY) === 'true')
watch(_listenMode, (v) => {
localStorage.setItem(LISTEN_MODE_KEY, String(v))
})
export function useListenMode() {
return _listenMode
}
@@ -0,0 +1,66 @@
import { ref, readonly } from 'vue'
export interface SilenceDetectorOptions {
thresholdDb?: number // default -40
silenceDurationMs?: number // default 1500
minRecordingMs?: number // default 500
}
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
const {
thresholdDb = -40,
silenceDurationMs = 1500,
minRecordingMs = 500,
} = options
const amplitude = ref(0)
let audioCtx: AudioContext | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let silenceMs = 0
let startedAt = 0
function start(stream: MediaStream, onSilence: () => void): void {
stop()
audioCtx = new AudioContext()
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
silenceMs = 0
startedAt = Date.now()
intervalId = setInterval(() => {
analyser.getByteFrequencyData(data)
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
amplitude.value = rms
const db = rms > 0 ? 20 * Math.log10(rms) : -100
if (db < thresholdDb) {
silenceMs += 100
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
stop()
onSilence()
}
} else {
silenceMs = 0
}
}, 100)
}
function stop(): void {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
if (audioCtx) {
audioCtx.close().catch(() => {})
audioCtx = null
}
amplitude.value = 0
silenceMs = 0
}
return { amplitude: readonly(amplitude), start, stop }
}
+168
View File
@@ -0,0 +1,168 @@
import { ref, watch, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { synthesiseSpeech } from '@/api/client'
import { useVoiceAudio } from '@/composables/useVoiceAudio'
/** Minimum stripped character count to bother synthesizing. */
const MIN_CHARS = 3
/** Matches sentence-terminal punctuation followed by whitespace or end-of-string. */
const SENTENCE_BOUNDARY = /[.!?]+(?=\s|$)/
function stripMarkdown(text: string): string {
return text
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
.replace(/#{1,6}\s+/g, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/\n{2,}/g, ' ')
.trim()
}
/**
* Extract completed sentences from `text` using SENTENCE_BOUNDARY.
* Returns the sentences found and the unconsumed remainder.
*/
function extractSentences(text: string): { sentences: string[]; remainder: string } {
const sentences: string[] = []
let remaining = text
let match: RegExpExecArray | null
while ((match = SENTENCE_BOUNDARY.exec(remaining)) !== null) {
const boundary = match.index + match[0].length
const sentence = remaining.slice(0, boundary).trim()
if (sentence) sentences.push(sentence)
remaining = remaining.slice(boundary)
}
return { sentences, remainder: remaining }
}
export interface UseStreamingTtsOptions {
streamingContent: Ref<string> | ComputedRef<string>
streaming: Ref<boolean> | ComputedRef<boolean>
enabled: Ref<boolean> | ComputedRef<boolean>
}
export interface UseStreamingTtsReturn {
/** True while any synthesis request is in-flight or audio is playing. */
speaking: ComputedRef<boolean>
/** Cancel all in-flight synthesis/playback and clear the queue. */
stop: () => void
/** Speak a complete text through the same sentence-chunking pipeline. */
speak: (text: string) => void
}
export function useStreamingTts(options: UseStreamingTtsOptions): UseStreamingTtsReturn {
const { streamingContent, streaming, enabled } = options
const audio = useVoiceAudio()
let sentenceBuffer = ''
let lastSeenLength = 0
let abortId = 0
let playQueue: Promise<void> = Promise.resolve()
const pendingCount = ref(0)
const speaking = computed(() => pendingCount.value > 0 || audio.playing.value)
function stop(): void {
abortId++
sentenceBuffer = ''
lastSeenLength = 0
playQueue = Promise.resolve()
audio.stop()
pendingCount.value = 0
}
async function enqueueSentence(sentence: string, myAbortId: number): Promise<void> {
const stripped = stripMarkdown(sentence)
if (stripped.length < MIN_CHARS) return
pendingCount.value++
let blob: Blob | null = null
try {
blob = await synthesiseSpeech(stripped)
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e)
console.warn('[StreamingTTS] Synthesis failed, retrying', {
chars: stripped.length,
preview: stripped.slice(0, 80),
error: errMsg,
})
try {
blob = await synthesiseSpeech(stripped)
} catch (e2) {
const errMsg2 = e2 instanceof Error ? e2.message : String(e2)
console.warn('[StreamingTTS] Retry failed, sentence dropped', {
chars: stripped.length,
preview: stripped.slice(0, 80),
error: errMsg2,
})
}
} finally {
pendingCount.value--
}
if (!blob) return
// Capture blob for the closure — TS can't narrow after async gap
const resolvedBlob = blob
playQueue = playQueue.then(async () => {
if (abortId !== myAbortId) return
await audio.play(resolvedBlob)
})
}
function dispatchBuffer(flush: boolean): void {
if (!enabled.value) return
const myAbortId = abortId
const { sentences, remainder } = extractSentences(sentenceBuffer)
sentenceBuffer = flush ? '' : remainder
for (const sentence of sentences) {
enqueueSentence(sentence, myAbortId)
}
if (flush && remainder.trim().length >= MIN_CHARS) {
enqueueSentence(remainder.trim(), myAbortId)
}
}
// Watch accumulating content — extract new characters since last check
watch(streamingContent, (newContent) => {
if (!enabled.value) return
const delta = newContent.slice(lastSeenLength)
lastSeenLength = newContent.length
sentenceBuffer += delta
dispatchBuffer(false)
})
// Watch streaming flag — stop on new message start, flush on end
watch(streaming, (isStreaming) => {
if (!enabled.value) return
if (isStreaming) {
// New message starting — cancel previous response's audio
stop()
} else {
// Stream ended — flush any remaining fragment
dispatchBuffer(true)
lastSeenLength = 0
}
})
function speak(text: string): void {
stop()
if (!text.trim()) return
const myAbortId = abortId
const { sentences, remainder } = extractSentences(text)
for (const sentence of sentences) {
enqueueSentence(sentence, myAbortId)
}
if (remainder.trim().length >= MIN_CHARS) {
enqueueSentence(remainder.trim(), myAbortId)
}
}
return { speaking, stop, speak }
}
+72
View File
@@ -0,0 +1,72 @@
import { ref, readonly } from 'vue'
const VOLUME_KEY = 'fa_voice_volume'
// Shared volume across all instances — persisted to localStorage per device
const _volume = ref<number>(parseFloat(localStorage.getItem(VOLUME_KEY) ?? '1.0'))
export function useVoiceAudio() {
const playing = ref(false)
const isSupported = typeof AudioContext !== 'undefined' || typeof (window as unknown as Record<string, unknown>).webkitAudioContext !== 'undefined'
let audioCtx: AudioContext | null = null
let currentSource: AudioBufferSourceNode | null = null
function _getCtx(): AudioContext {
if (!audioCtx || audioCtx.state === 'closed') {
const Ctx = window.AudioContext ?? (window as unknown as Record<string, typeof AudioContext>).webkitAudioContext
audioCtx = new Ctx()
}
return audioCtx
}
async function play(blob: Blob): Promise<void> {
stop()
const ctx = _getCtx()
if (ctx.state === 'suspended') await ctx.resume()
const arrayBuffer = await blob.arrayBuffer()
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
const source = ctx.createBufferSource()
source.buffer = audioBuffer
const gain = ctx.createGain()
gain.gain.value = _volume.value
source.connect(gain)
gain.connect(ctx.destination)
currentSource = source
playing.value = true
return new Promise<void>((resolve) => {
source.onended = () => {
playing.value = false
currentSource = null
resolve()
}
source.start(0)
})
}
function stop(): void {
if (currentSource) {
try { currentSource.stop() } catch { /* already stopped */ }
currentSource = null
}
playing.value = false
}
return {
playing: readonly(playing),
volume: _volume,
isSupported,
play,
stop,
}
}
export function setVoiceVolume(v: number) {
_volume.value = Math.max(0, Math.min(1, v))
localStorage.setItem(VOLUME_KEY, String(_volume.value))
}
@@ -0,0 +1,93 @@
import { ref, readonly, type Ref } from 'vue'
/**
* Push-to-talk recorder wrapping the browser MediaRecorder API.
*
* Usage:
* const { recording, error, isSupported, startRecording, stopRecording } = useVoiceRecorder()
* await startRecording()
* const blob = await stopRecording() // resolves with the recorded audio Blob
*/
export function useVoiceRecorder() {
const recording = ref(false)
const error = ref<string | null>(null)
const isSupported = typeof MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
let mediaRecorder: MediaRecorder | null = null
let chunks: Blob[] = []
const streamRef = ref<MediaStream | null>(null)
let resolveStop: ((blob: Blob) => void) | null = null
let rejectStop: ((err: Error) => void) | null = null
async function startRecording(): Promise<void> {
error.value = null
if (!isSupported) {
error.value = 'Audio recording is not supported in this browser'
return
}
if (recording.value) return
try {
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (e) {
error.value = 'Microphone access denied'
return
}
chunks = []
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: ''
mediaRecorder = mimeType ? new MediaRecorder(streamRef.value!, { mimeType }) : new MediaRecorder(streamRef.value!)
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => {
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType ?? 'audio/webm' })
chunks = []
streamRef.value?.getTracks().forEach((t) => t.stop())
streamRef.value = null
recording.value = false
resolveStop?.(blob)
resolveStop = null
rejectStop = null
}
mediaRecorder.onerror = () => {
recording.value = false
error.value = 'Recording error'
rejectStop?.(new Error('MediaRecorder error'))
resolveStop = null
rejectStop = null
}
mediaRecorder.start(100) // collect in 100ms chunks
recording.value = true
}
function stopRecording(): Promise<Blob> {
return new Promise((resolve, reject) => {
if (!mediaRecorder || !recording.value) {
reject(new Error('Not recording'))
return
}
resolveStop = resolve
rejectStop = reject
mediaRecorder.stop()
})
}
return {
recording: readonly(recording),
error: readonly(error),
isSupported,
startRecording,
stopRecording,
stream: readonly(streamRef) as Readonly<Ref<MediaStream | null>>,
}
}
+6 -3
View File
@@ -1,5 +1,4 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "@/views/HomeView.vue";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({
@@ -7,8 +6,12 @@ const router = createRouter({
routes: [
{
path: "/",
name: "home",
component: HomeView,
name: "knowledge",
component: () => import("@/views/KnowledgeView.vue"),
},
{
path: "/knowledge",
redirect: "/",
},
{
path: "/login",
+3 -1
View File
@@ -80,6 +80,8 @@ export const useNotesStore = defineStore("notes", () => {
tags?: string[];
project_id?: number | null;
milestone_id?: number | null;
note_type?: string;
metadata?: Record<string, string> | null;
}): Promise<Note> {
try {
return await apiPost<Note>("/api/notes", data);
@@ -91,7 +93,7 @@ export const useNotesStore = defineStore("notes", () => {
async function updateNote(
id: number,
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id">>
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type" | "metadata">>
): Promise<Note> {
try {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
+23 -1
View File
@@ -1,6 +1,6 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPut } from "@/api/client";
import { apiGet, apiPut, getVoiceStatus } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { AppSettings } from "@/types/settings";
@@ -16,6 +16,24 @@ export const useSettingsStore = defineStore("settings", () => {
() => settings.value.default_model || ""
);
// Voice status — checked once on login, refreshable from Settings
const voiceEnabled = ref(false);
const voiceSttReady = ref(false);
const voiceTtsReady = ref(false);
async function checkVoiceStatus() {
try {
const s = await getVoiceStatus();
voiceEnabled.value = s.enabled;
voiceSttReady.value = s.enabled && s.stt;
voiceTtsReady.value = s.enabled && s.tts;
} catch {
voiceEnabled.value = false;
voiceSttReady.value = false;
voiceTtsReady.value = false;
}
}
async function fetchSettings() {
loading.value = true;
try {
@@ -44,6 +62,10 @@ export const useSettingsStore = defineStore("settings", () => {
loading,
assistantName,
defaultModel,
voiceEnabled,
voiceSttReady,
voiceTtsReady,
checkVoiceStatus,
fetchSettings,
updateSettings,
};
+1
View File
@@ -28,6 +28,7 @@ export interface Message {
context_note_id: number | null;
context_note_title?: string | null;
tool_calls?: ToolCallRecord[] | null;
metadata?: Record<string, unknown> | null;
created_at: string;
timing?: GenerationTiming;
thinking?: string;
+1
View File
@@ -3,6 +3,7 @@ export interface NewsItem {
title: string
url: string
snippet: string
content: string
published_at: string | null
topics: string[]
source: string
+3
View File
@@ -1,5 +1,6 @@
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "none" | "low" | "medium" | "high";
export type NoteType = "note" | "person" | "place" | "list";
export interface Note {
id: number;
@@ -18,6 +19,8 @@ export interface Note {
recurrence_rule: Record<string, unknown> | null;
recurrence_next_spawn_at: string | null;
is_task: boolean;
note_type: NoteType;
metadata: Record<string, string>;
created_at: string;
updated_at: string;
}
+65
View File
@@ -0,0 +1,65 @@
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
function _isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
}
/** "9:30 AM" */
export function fmtTime(dt: string): string {
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
}
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
export function fmtDateTime(dt: string, allDay: boolean): string {
const d = new Date(dt)
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
if (allDay) return datePart
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
}
/**
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM"
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15"
*/
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
try {
const d = new Date(dt)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
const timeStr = allDay ? "" : ` ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
if (_isSameDay(d, now)) return `Today${timeStr}`
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}`
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr
} catch {
return dt
}
}
/**
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15"
*/
export function fmtDayLabel(dt: string): string {
try {
const d = new Date(dt)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
if (_isSameDay(d, now)) return "Today"
if (_isSameDay(d, tomorrow)) return "Tomorrow"
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
} catch {
return dt
}
}
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */
export function fmtCompact(dt: string, allDay: boolean): string {
const d = new Date(dt)
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
}
+15 -3
View File
@@ -38,7 +38,7 @@ const headingRenderer = {
marked.use({ renderer: headingRenderer });
const PURIFY_OPTS_FULL = {
ADD_ATTR: ["data-tag", "data-title", "src", "alt"],
ADD_ATTR: ["data-tag", "data-title", "data-task-index"],
FORCE_BODY: true,
};
@@ -47,10 +47,22 @@ const PURIFY_OPTS_PREVIEW = {
FORCE_BODY: true,
};
export function renderMarkdown(text: string): string {
export function renderMarkdown(text: string, { interactiveCheckboxes = false } = {}): string {
const stripped = stripFirstLineTags(text);
const decoded = decodeEntities(stripped);
const html = marked(decoded) as string;
let html = marked(decoded) as string;
if (interactiveCheckboxes) {
// marked renders task-list checkboxes as <input ... disabled="" ...>.
// Remove disabled and stamp a sequential data-task-index so the viewer
// can identify which item was toggled regardless of attribute order.
html = html.replace(/ disabled=""/g, "");
let taskIdx = 0;
html = html.replace(/<input ([^>]*type="checkbox"[^>]*)>/g, (_m, attrs) => {
return `<input data-task-index="${taskIdx++}" ${attrs}>`;
});
}
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_FULL);
+44 -206
View File
@@ -1,24 +1,22 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat'
import ChatMessage from '@/components/ChatMessage.vue'
import ChatPanel from '@/components/ChatPanel.vue'
import WeatherCard from '@/components/WeatherCard.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
import {
apiGet,
apiPost,
getBriefingConfig,
getBriefingConversations,
getBriefingToday,
getBriefingConvMessages,
triggerBriefingSlot,
postRssReaction,
deleteRssReaction,
getNewsItems,
type BriefingConversation,
type BriefingMessage,
} from '@/api/client'
import type { Message } from '@/types/chat'
import type { NewsItem } from '@/types/news'
interface WeatherData {
@@ -30,7 +28,8 @@ interface WeatherData {
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
forecast: { day: string; condition: string; high: number; low: number }[]
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()
@@ -57,10 +56,6 @@ const todayConvId = ref<number | null>(null)
const isToday = computed(() => selectedConvId.value === todayConvId.value)
// Messages for selected conversation
const messages = ref<BriefingMessage[]>([])
const loadingMessages = ref(false)
// Weather panel (left column)
const weatherData = ref<WeatherData[]>([])
const tempUnit = ref<string>('C')
@@ -89,17 +84,6 @@ async function loadNews() {
} catch { /* silent */ }
}
// Scroll to bottom of messages
const messagesEl = ref<HTMLElement | null>(null)
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
})
}
async function loadAll() {
const [convList, today] = await Promise.all([
getBriefingConversations(),
@@ -117,62 +101,32 @@ async function loadAll() {
]
}
selectedConvId.value = today.id
messages.value = today.messages
await chatStore.fetchConversation(today.id)
}
scrollToBottom()
}
watch(selectedConvId, async (id) => {
if (!id) return
if (id === todayConvId.value) {
try {
await chatStore.fetchConversation(id)
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
scrollToBottom()
} 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
}
loadingMessages.value = true
try {
messages.value = await getBriefingConvMessages(id)
scrollToBottom()
} finally {
loadingMessages.value = false
}
})
// Refresh messages after streaming ends
watch(() => chatStore.streaming, async (streaming) => {
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
const today = await getBriefingToday().catch(() => null)
if (today) messages.value = today.messages
scrollToBottom()
}
})
// Input
const input = ref('')
const sending = ref(false)
async function send() {
const text = input.value.trim()
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
if (chatStore.currentConversation?.id !== todayConvId.value) {
await chatStore.fetchConversation(todayConvId.value)
}
input.value = ''
sending.value = true
try {
await chatStore.sendMessage(text)
} finally {
sending.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
await chatStore.fetchConversation(todayConvId.value)
await chatStore.reconnectIfGenerating(todayConvId.value)
}
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
@@ -208,9 +162,10 @@ async function triggerNow() {
triggering.value = true
try {
await triggerBriefingSlot('compilation')
await loadAll()
// Guard: user may have navigated away during the long compilation
if (_mounted) await loadAll()
} finally {
triggering.value = false
if (_mounted) triggering.value = false
}
}
@@ -224,29 +179,13 @@ function convLabel(c: BriefingConversation): string {
return c.title || 'Briefing'
}
// Convert BriefingMessage to Message for ChatMessage component
function toMsg(m: BriefingMessage): Message {
return {
id: m.id,
conversation_id: -1,
role: m.role,
content: m.content,
context_note_id: null,
context_note_title: null,
created_at: m.created_at,
}
}
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
async function _backgroundRefreshMessages() {
try {
const today = await getBriefingToday()
if (!today) return
const fresh = today.messages
const last = fresh[fresh.length - 1]
const cur = messages.value[messages.value.length - 1]
if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
messages.value = fresh
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 */ }
@@ -258,6 +197,9 @@ useBackgroundRefresh(
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
)
let _mounted = true
onUnmounted(() => { _mounted = false })
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
@@ -306,58 +248,13 @@ onMounted(async () => {
<!-- Center column: Chat -->
<div class="briefing-center">
<div class="briefing-messages-wrap" ref="messagesEl">
<div v-if="loadingMessages" class="briefing-loading">Loading</div>
<template v-else>
<div v-if="!messages.length" class="briefing-empty">
<p>No briefing yet for today.</p>
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
</div>
<div v-else class="briefing-messages">
<template v-for="msg in messages" :key="msg.id">
<ChatMessage
:message="toMsg(msg)"
:is-streaming="false"
/>
</template>
<!-- Live streaming bubble for today -->
<ChatMessage
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
:message="{
id: -1,
conversation_id: todayConvId ?? -1,
role: 'assistant',
content: chatStore.streamingContent,
context_note_id: null,
context_note_title: null,
created_at: new Date().toISOString(),
}"
:is-streaming="true"
/>
</div>
</template>
</div>
<!-- Input bar (today only) -->
<div v-if="isToday" class="briefing-input-bar">
<textarea
v-model="input"
class="briefing-input"
placeholder="Reply to your briefing…"
rows="1"
@keydown="onKeydown"
></textarea>
<button
class="btn-send"
@click="send"
:disabled="!input.trim() || chatStore.streaming || sending"
aria-label="Send"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
</svg>
</button>
</div>
<ChatPanel
variant="full"
briefingMode
:readOnly="!isToday"
placeholder="Reply to your briefing…"
class="briefing-chat-panel"
/>
</div>
<!-- Right column: News -->
@@ -398,6 +295,12 @@ onMounted(async () => {
@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>
@@ -515,76 +418,11 @@ onMounted(async () => {
min-height: 0;
}
.briefing-messages-wrap {
.briefing-chat-panel {
flex: 1;
overflow-y: auto;
padding: 1rem;
min-height: 0;
}
.briefing-loading,
.briefing-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.briefing-empty-hint {
font-size: 0.8rem;
opacity: 0.7;
margin-top: 0.5rem;
}
.briefing-messages {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.briefing-input-bar {
display: flex;
gap: 0.5rem;
align-items: flex-end;
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.briefing-input {
flex: 1;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
resize: none;
outline: none;
font-family: inherit;
line-height: 1.4;
max-height: 120px;
overflow-y: auto;
transition: border-color 0.15s;
}
.briefing-input:focus { border-color: var(--color-primary); }
.btn-send {
padding: 0.55rem 0.75rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
flex-shrink: 0;
}
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send:hover:not(:disabled) { opacity: 0.9; }
/* ─── Right column (News) ────────────────────────────────────────────────── */
.briefing-right {
+504 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, computed, onMounted, onUnmounted } from "vue";
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
@@ -9,12 +9,13 @@ import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { useToastStore } from "@/stores/toast";
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
const toast = useToastStore();
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
// Slide-over state
const slideOverEvent = ref<EventEntry | null>(null); // null = create mode
const slideOverEvent = ref<EventEntry | null>(null);
const slideOverOpen = ref(false);
const slideOverDate = ref<string>("");
@@ -34,15 +35,17 @@ function closeSlideOver() {
slideOverOpen.value = false;
}
// Event entry cache keyed by id for quick lookups when clicking FC events
// Event entry cache keyed by id
const eventCache = new Map<number, EventEntry>();
function toFcEvent(e: EventEntry) {
// For all-day events pass date-only strings so FullCalendar never shifts
// the date through timezone conversion (UTC midnight → previous day in UTC-X).
return {
id: String(e.id),
title: e.title,
start: e.start_dt,
end: e.end_dt ?? undefined,
start: e.all_day ? e.start_dt.slice(0, 10) : e.start_dt,
end: e.all_day ? (e.end_dt?.slice(0, 10) ?? undefined) : (e.end_dt ?? undefined),
allDay: e.all_day,
backgroundColor: e.color || undefined,
borderColor: e.color || undefined,
@@ -50,6 +53,128 @@ function toFcEvent(e: EventEntry) {
};
}
// ── Upcoming events list ───────────────────────────────────────────────────
const upcomingEvents = ref<EventEntry[]>([]);
async function loadUpcoming() {
const now = new Date();
const end = new Date(now.getTime() + 28 * 86_400_000); // 4 weeks
try {
const entries = await listEvents(now.toISOString(), end.toISOString());
upcomingEvents.value = entries.sort(
(a, b) => new Date(a.start_dt).getTime() - new Date(b.start_dt).getTime()
);
} catch { /* silent */ }
}
onMounted(loadUpcoming);
// ── Month/year picker ──────────────────────────────────────────────────────
const currentViewYear = ref(new Date().getFullYear());
const currentViewMonth = ref(new Date().getMonth());
const pickerOpen = ref(false);
const pickerYear = ref(new Date().getFullYear());
const pickerStyle = ref<Record<string, string>>({});
const pickerEl = ref<HTMLElement | null>(null);
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
function handleDatesSet(arg: { view: { currentStart: Date } }) {
const d = arg.view.currentStart;
currentViewYear.value = d.getFullYear();
currentViewMonth.value = d.getMonth();
}
function jumpTo(year: number, month: number) {
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
pickerOpen.value = false;
}
// ── Event popover ──────────────────────────────────────────────────────────
const popover = ref<EventEntry | null>(null);
const popoverStyle = ref<Record<string, string>>({});
const popoverEl = ref<HTMLElement | null>(null);
function showPopover(entry: EventEntry, clickEvent: MouseEvent) {
popover.value = entry;
nextTickPositionPopover(clickEvent);
}
function nextTickPositionPopover(clickEvent: MouseEvent) {
// Position after DOM update
requestAnimationFrame(() => {
const vw = window.innerWidth;
const vh = window.innerHeight;
const pw = 280;
const ph = 220; // approximate
let left = clickEvent.clientX + 8;
let top = clickEvent.clientY + 8;
if (left + pw > vw - 16) left = clickEvent.clientX - pw - 8;
if (top + ph > vh - 16) top = clickEvent.clientY - ph - 8;
popoverStyle.value = {
position: "fixed",
left: `${Math.max(8, left)}px`,
top: `${Math.max(8, top)}px`,
zIndex: "9999",
};
});
}
function closePopover() {
popover.value = null;
}
function onPopoverEdit() {
if (popover.value) {
openEdit(popover.value);
closePopover();
}
}
// Close popover / open picker on outside or title click
function onDocClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Title click → toggle month/year picker
const titleEl = target.closest(".fc-toolbar-title");
if (titleEl) {
if (!pickerOpen.value) {
pickerYear.value = currentViewYear.value;
const rect = titleEl.getBoundingClientRect();
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
pickerStyle.value = {
position: "fixed",
top: `${rect.bottom + 6}px`,
left: `${left}px`,
zIndex: "9999",
};
}
pickerOpen.value = !pickerOpen.value;
return;
}
// Close picker on outside click
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
pickerOpen.value = false;
}
// Close event popover on outside click
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
closePopover();
}
}
function onCalendarChanged() {
calendarRef.value?.getApi().refetchEvents();
}
onMounted(() => {
document.addEventListener("mousedown", onDocClick);
document.addEventListener("fable:calendar-changed", onCalendarChanged);
});
onUnmounted(() => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("fable:calendar-changed", onCalendarChanged);
});
// ── Calendar callbacks ─────────────────────────────────────────────────────
async function loadEvents(
fetchInfo: { startStr: string; endStr: string },
successCallback: (events: object[]) => void,
@@ -60,19 +185,21 @@ async function loadEvents(
eventCache.clear();
for (const e of entries) eventCache.set(e.id, e);
successCallback(entries.map(toFcEvent));
loadUpcoming();
} catch (err) {
failureCallback(err instanceof Error ? err : new Error(String(err)));
}
}
function handleDateClick(arg: DateClickArg) {
closePopover();
openCreate(arg.dateStr);
}
function handleEventClick(arg: EventClickArg) {
const id = arg.event.extendedProps.entryId as number;
const entry = eventCache.get(id);
if (entry) openEdit(entry);
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
}
async function handleEventDrop(arg: EventDropArg) {
@@ -82,6 +209,7 @@ async function handleEventDrop(arg: EventDropArg) {
try {
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
eventCache.set(id, updated);
loadUpcoming();
} catch {
arg.revert();
toast.show("Failed to move event", "error");
@@ -95,6 +223,7 @@ async function handleEventResize(arg: EventResizeDoneArg) {
try {
const updated = await updateEvent(id, { start_dt, end_dt });
eventCache.set(id, updated);
loadUpcoming();
} catch {
arg.revert();
toast.show("Failed to resize event", "error");
@@ -105,11 +234,11 @@ function onCreated(entry: EventEntry) {
eventCache.set(entry.id, entry);
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
closeSlideOver();
loadUpcoming();
}
function onUpdated(entry: EventEntry) {
eventCache.set(entry.id, entry);
// Replace the event in FullCalendar
const api = calendarRef.value?.getApi();
if (api) {
const existing = api.getEventById(String(entry.id));
@@ -119,15 +248,15 @@ function onUpdated(entry: EventEntry) {
}
}
closeSlideOver();
loadUpcoming();
}
function onDeleted(id: number) {
eventCache.delete(id);
const api = calendarRef.value?.getApi();
if (api) {
api.getEventById(String(id))?.remove();
}
if (api) api.getEventById(String(id))?.remove();
closeSlideOver();
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
}
const calendarOptions: CalendarOptions = {
@@ -142,12 +271,28 @@ const calendarOptions: CalendarOptions = {
right: "dayGridMonth,timeGridWeek,timeGridDay",
},
events: loadEvents,
datesSet: handleDatesSet,
dateClick: handleDateClick,
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
height: "auto",
};
// Group upcoming events by day label
const upcomingGrouped = computed(() => {
const groups: { label: string; date: string; events: EventEntry[] }[] = [];
for (const e of upcomingEvents.value) {
const label = fmtDayLabel(e.start_dt);
const existing = groups.find((g) => g.label === label);
if (existing) {
existing.events.push(e);
} else {
groups.push({ label, date: e.start_dt, events: [e] });
}
}
return groups;
});
</script>
<template>
@@ -163,6 +308,99 @@ const calendarOptions: CalendarOptions = {
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
<!-- Upcoming events strip -->
<div v-if="upcomingEvents.length" class="upcoming-section">
<h2 class="upcoming-title">Upcoming</h2>
<div class="upcoming-groups">
<div v-for="group in upcomingGrouped" :key="group.label" class="upcoming-group">
<div class="upcoming-day-label">{{ group.label }}</div>
<div class="upcoming-cards">
<div
v-for="ev in group.events"
:key="ev.id"
class="upcoming-card"
:style="ev.color ? { '--ev-color': ev.color } : {}"
@click="openEdit(ev)"
>
<div class="upcoming-card-accent"></div>
<div class="upcoming-card-body">
<div class="upcoming-card-title">{{ ev.title }}</div>
<div class="upcoming-card-time">
<template v-if="ev.all_day">All day</template>
<template v-else>
{{ fmtTime(ev.start_dt) }}
<span v-if="ev.end_dt"> {{ fmtTime(ev.end_dt) }}</span>
</template>
</div>
<div v-if="ev.location" class="upcoming-card-meta">
<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>
{{ ev.location }}
</div>
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Event popover -->
<Teleport to="body">
<div
v-if="popover"
ref="popoverEl"
class="event-popover"
:style="popoverStyle"
>
<div class="popover-accent" :style="popover.color ? { background: popover.color } : {}"></div>
<div class="popover-content">
<div class="popover-title">{{ popover.title }}</div>
<div class="popover-time">
<template v-if="popover.all_day">
{{ fmtDateTime(popover.start_dt, true) }}
</template>
<template v-else>
{{ fmtDateTime(popover.start_dt, false) }}
<span v-if="popover.end_dt"> {{ fmtTime(popover.end_dt) }}</span>
</template>
</div>
<div v-if="popover.location" class="popover-meta">
<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>
{{ popover.location }}
</div>
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
<div class="popover-actions">
<button class="popover-btn popover-btn--edit" @click="onPopoverEdit">Edit</button>
<button class="popover-btn popover-btn--close" @click="closePopover">Close</button>
</div>
</div>
</div>
</Teleport>
<!-- Month/year picker -->
<Teleport to="body">
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
<div class="picker-year-row">
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year"></button>
<span class="picker-year-label">{{ pickerYear }}</span>
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year"></button>
</div>
<div class="picker-months">
<button
v-for="(name, i) in MONTH_NAMES"
:key="i"
class="picker-month"
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
@click="jumpTo(pickerYear, i)"
>{{ name }}</button>
</div>
</div>
</Teleport>
<EventSlideOver
v-if="slideOverOpen"
:event="slideOverEvent"
@@ -177,7 +415,7 @@ const calendarOptions: CalendarOptions = {
<style scoped>
.calendar-view {
max-width: 1200px;
max-width: var(--page-max-width);
margin: 0 auto;
padding: 1.5rem 1.5rem 3rem;
}
@@ -210,7 +448,7 @@ const calendarOptions: CalendarOptions = {
.btn-new-event:hover { opacity: 0.88; }
.fc-wrapper {
background: var(--color-surface, #1a1b1e);
background: var(--color-surface);
border: 1px solid var(--color-border, #2a2b30);
border-radius: var(--radius-lg, 18px);
padding: 1rem;
@@ -225,9 +463,17 @@ const calendarOptions: CalendarOptions = {
:deep(.fc-toolbar-title) {
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: background 0.15s;
user-select: none;
}
:deep(.fc-toolbar-title:hover) {
background: rgba(255,255,255,0.07);
}
:deep(.fc-button) {
background: var(--color-input-bg, #111113);
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text-muted, #888);
font-size: 0.82rem;
@@ -267,4 +513,249 @@ const calendarOptions: CalendarOptions = {
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
:deep(.fc-daygrid-day) { cursor: pointer; }
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
/* ── Month/year picker ──────────────────────────────────────────────────── */
.month-picker {
background: var(--color-bg-card);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
width: 280px;
padding: 12px 14px;
}
.picker-year-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.picker-year-label {
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text, #e8e9f0);
}
.picker-year-btn {
background: none;
border: 1px solid var(--color-border, #2a2b30);
border-radius: 6px;
color: var(--color-text-muted, #888);
cursor: pointer;
padding: 2px 10px;
font-size: 1rem;
line-height: 1.4;
transition: color 0.15s, border-color 0.15s;
}
.picker-year-btn:hover {
color: var(--color-text, #e8e9f0);
border-color: rgba(255,255,255,0.25);
}
.picker-months {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.picker-month {
padding: 7px 4px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--color-text, #e8e9f0);
cursor: pointer;
font-size: 0.84rem;
text-align: center;
transition: background 0.12s, color 0.12s;
}
.picker-month:hover {
background: rgba(255,255,255,0.08);
}
.picker-month.active {
background: rgba(99,102,241,0.22);
color: var(--color-primary, #818cf8);
font-weight: 700;
}
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
.upcoming-section {
margin-top: 2rem;
}
.upcoming-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.78rem;
margin: 0 0 1rem;
}
.upcoming-groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.upcoming-day-label {
font-size: 0.8rem;
font-weight: 700;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.upcoming-cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upcoming-card {
display: flex;
align-items: stretch;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.upcoming-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
background: var(--color-bg-secondary);
}
.upcoming-card-accent {
width: 4px;
flex-shrink: 0;
background: var(--ev-color, #6366f1);
}
.upcoming-card-body {
padding: 0.6rem 0.85rem;
flex: 1;
min-width: 0;
}
.upcoming-card-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text, #e8e9f0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upcoming-card-time {
font-size: 0.78rem;
color: var(--color-text-muted, #888);
margin-top: 0.15rem;
}
.upcoming-card-meta {
display: flex;
align-items: flex-start;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--color-text-muted, #888);
margin-top: 0.25rem;
}
.upcoming-card-desc {
font-size: 0.8rem;
color: var(--color-text-secondary, #aaa);
margin-top: 0.3rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Event popover ──────────────────────────────────────────────────────── */
.event-popover {
background: var(--color-bg-card);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
width: 280px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.popover-accent {
height: 4px;
background: var(--color-primary, #6366f1);
}
.popover-content {
padding: 0.85rem 1rem 0.75rem;
}
.popover-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text, #e8e9f0);
margin-bottom: 0.35rem;
}
.popover-time {
font-size: 0.8rem;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.popover-meta {
display: flex;
align-items: flex-start;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.popover-desc {
font-size: 0.82rem;
color: var(--color-text-secondary, #aaa);
line-height: 1.45;
margin-bottom: 0.6rem;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.popover-actions {
display: flex;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #2a2b30);
}
.popover-btn {
flex: 1;
padding: 0.35rem 0;
border: none;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.popover-btn:hover { opacity: 0.85; }
.popover-btn--edit {
background: var(--color-primary, #6366f1);
color: #fff;
}
.popover-btn--close {
background: var(--color-input-bg, var(--color-bg));
color: var(--color-text-muted, #888);
border: 1px solid var(--color-border, #2a2b30);
}
</style>
File diff suppressed because it is too large Load Diff
+22 -195
View File
@@ -1,18 +1,17 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ref, onMounted, onUnmounted } from "vue";
import { apiGet, listEvents } from "@/api/client";
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh";
import { milestoneColor } from "@/utils/palette";
import { fmtRelativeDateTime } from "@/utils/dateFormat";
import type { Note } from "@/types/note";
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
import type { ToolCallRecord, Message } from "@/types/chat";
import type { EventEntry } from "@/api/client";
import NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import DashboardChatInput from "@/components/DashboardChatInput.vue";
import ChatPanel from "@/components/ChatPanel.vue";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { useTasksStore } from "@/stores/tasks";
import { useChatStore } from "@/stores/chat";
@@ -78,9 +77,11 @@ function _dateRange() {
const today = new Date()
const nextWeek = new Date(today)
nextWeek.setDate(today.getDate() + 7)
// Use full ISO strings so the server sees the correct UTC equivalent of
// local midnight / end-of-day rather than a naive UTC-midnight guess.
return {
todayStr: today.toISOString().slice(0, 10) + 'T00:00:00',
nextWeekStr: nextWeek.toISOString().slice(0, 10) + 'T23:59:59',
todayStr: today.toISOString(),
nextWeekStr: nextWeek.toISOString(),
}
}
@@ -109,11 +110,7 @@ function _backgroundRefresh() {
onMounted(async () => {
// Phase 1: projects list + cross-project recent items + orphaned items + events — all parallel
const today = new Date();
const todayStr = today.toISOString().slice(0, 10) + "T00:00:00";
const nextWeek = new Date(today);
nextWeek.setDate(today.getDate() + 7);
const nextWeekStr = nextWeek.toISOString().slice(0, 10) + "T23:59:59";
const { todayStr, nextWeekStr } = _dateRange();
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes, eventsRes] =
await Promise.allSettled([
@@ -156,7 +153,7 @@ onMounted(async () => {
loading.value = false;
// Focus chat input after data loads
chatInputRef.value?.focus();
chatPanelRef.value?.focus();
loadProjects();
});
@@ -215,10 +212,10 @@ function onStatusToggle(id: number, status: TaskStatus) {
// ─── Chat widget ──────────────────────────────────────────────────────────────
const chatInputRef = ref<{ focus: () => void } | null>(null);
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
function onFocusChatShortcut() {
chatInputRef.value?.focus();
chatPanelRef.value?.focus();
}
onMounted(() => {
@@ -234,16 +231,6 @@ chatStore.fetchStatus().then(() => {
if (chatStore.defaultModel) chatStore.warmModel(chatStore.defaultModel);
});
const dashboardConvId = ref<number | null>(null);
const dashboardDone = ref(false);
const dashboardQuery = ref("");
const dashboardFinalContent = ref("");
const dashboardFinalToolCalls = ref<ToolCallRecord[]>([]);
const isConversational = computed(
() => dashboardDone.value && dashboardFinalToolCalls.value.length === 0
);
const QUICK_ACTIONS = [
"What's due today?",
"Events this week?",
@@ -251,38 +238,8 @@ const QUICK_ACTIONS = [
"My high priority tasks?",
];
async function onChatSubmit(payload: { content: string; contextNoteId?: number }) {
dashboardConvId.value = null;
dashboardDone.value = false;
dashboardFinalContent.value = "";
dashboardFinalToolCalls.value = [];
dashboardQuery.value = payload.content;
const conv = await chatStore.createConversation();
await chatStore.fetchConversation(conv.id);
dashboardConvId.value = conv.id;
await chatStore.sendMessage(payload.content, payload.contextNoteId);
const msgs = chatStore.currentConversation?.messages ?? [];
const lastAssistant = [...msgs]
.reverse()
.find((m: Message) => m.role === "assistant");
dashboardFinalContent.value = lastAssistant?.content ?? "";
dashboardFinalToolCalls.value = (lastAssistant?.tool_calls ?? []) as ToolCallRecord[];
dashboardDone.value = true;
}
async function onQuickAction(query: string) {
await onChatSubmit({ content: query });
}
function clearDashboardResponse() {
dashboardConvId.value = null;
dashboardDone.value = false;
dashboardQuery.value = "";
dashboardFinalContent.value = "";
dashboardFinalToolCalls.value = [];
await chatPanelRef.value?.send(query);
}
// ─── Upcoming events slide-over ───────────────────────────────────────────────
@@ -304,28 +261,8 @@ function onEventDeleted(id: number) {
}
function formatUpcomingTime(event: EventEntry): string {
if (event.all_day) return "All day";
if (!event.start_dt) return "";
try {
const d = new Date(event.start_dt);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const isToday =
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const isTomorrow =
d.getFullYear() === tomorrow.getFullYear() &&
d.getMonth() === tomorrow.getMonth() &&
d.getDate() === tomorrow.getDate();
const timeStr = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
if (isToday) return `Today ${timeStr}`;
if (isTomorrow) return `Tomorrow ${timeStr}`;
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + " " + timeStr;
} catch {
return event.start_dt;
}
return fmtRelativeDateTime(event.start_dt, event.all_day);
}
</script>
@@ -343,38 +280,9 @@ function formatUpcomingTime(event: EventEntry): string {
@click="onQuickAction(q)"
>{{ q }}</button>
</div>
<DashboardChatInput ref="chatInputRef" @submit="onChatSubmit" />
<ChatPanel ref="chatPanelRef" variant="widget" />
</section>
<!-- Inline response -->
<div v-if="dashboardConvId" class="dashboard-response">
<div class="dashboard-response-query">{{ dashboardQuery }}</div>
<div v-if="chatStore.streaming && chatStore.streamingToolCalls.length" class="dashboard-tool-calls">
<ToolCallCard v-for="(tc, i) in chatStore.streamingToolCalls" :key="i" :tool-call="tc" />
</div>
<div v-else-if="dashboardDone && dashboardFinalToolCalls.length" class="dashboard-tool-calls">
<ToolCallCard v-for="(tc, i) in dashboardFinalToolCalls" :key="i" :tool-call="tc" />
</div>
<div v-if="chatStore.streaming" class="dashboard-response-text streaming">
<div v-if="chatStore.streamingStatus && !chatStore.streamingContent" class="dashboard-status-line">
<span class="dashboard-status-dot"></span>{{ chatStore.streamingStatus }}
</div>
<span v-else-if="chatStore.streamingContent">{{ chatStore.streamingContent }}</span>
<span v-else class="thinking-dots">...</span>
</div>
<div v-else-if="dashboardDone && dashboardFinalContent" class="dashboard-response-text">
{{ dashboardFinalContent }}
</div>
<div class="dashboard-response-actions" :class="{ conversational: isConversational }">
<router-link
:to="`/chat/${dashboardConvId}`"
class="btn-open-chat"
:class="{ prominent: isConversational }"
>{{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }}</router-link>
<button class="btn-clear-response" @click="clearDashboardResponse">Clear</button>
</div>
</div>
<!-- Upcoming events -->
<div v-if="!loading && upcomingEvents.length" class="upcoming-events-section">
<div class="section-header">
@@ -593,16 +501,20 @@ function formatUpcomingTime(event: EventEntry): string {
<style scoped>
.home {
max-width: 1100px;
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 1.5rem;
padding: 0 var(--page-padding-x);
}
/* ─── Chat widget ────────────────────────────────────────────── */
.chat-section { margin-bottom: 1rem; }
.chat-section {
max-width: 720px;
margin: 0 auto 1.5rem;
}
.quick-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
@@ -623,91 +535,6 @@ function formatUpcomingTime(event: EventEntry): string {
}
.quick-action-chip:disabled { opacity: 0.4; cursor: default; }
/* ─── Inline response ────────────────────────────────────────── */
.dashboard-response {
margin-bottom: 1.5rem;
padding: 0.75rem 1rem;
background: var(--color-bg-card);
border-left: 2px solid var(--color-primary);
border-radius: var(--radius-md);
box-shadow: var(--color-bubble-asst-shadow);
}
.dashboard-response-query {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.dashboard-tool-calls {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.dashboard-response-text {
font-size: 0.9rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.dashboard-response-text.streaming { color: var(--color-text-muted); }
.thinking-dots { display: inline-block; animation: blink 1.2s infinite; }
.dashboard-status-line {
display: flex;
align-items: center;
gap: 0.4rem;
font-style: italic;
}
.dashboard-status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
animation: blink 1.2s infinite;
flex-shrink: 0;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.dashboard-response-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
}
.btn-open-chat {
font-size: 0.85rem;
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
}
.btn-open-chat:hover { text-decoration: underline; }
.btn-open-chat.prominent {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
padding: 0.35rem 0.85rem;
border-radius: var(--radius-sm);
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-open-chat.prominent:hover {
text-decoration: none;
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.btn-clear-response {
font-size: 0.8rem;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.btn-clear-response:hover { color: var(--color-text); }
/* ─── Skeleton loading ───────────────────────────────────────── */
.skeleton-hero {
height: 180px;
@@ -1034,7 +861,7 @@ function formatUpcomingTime(event: EventEntry): string {
/* ─── Mobile ─────────────────────────────────────────────────── */
@media (max-width: 680px) {
.home { padding: 0 1rem; margin: 1rem auto; }
.home { padding: 0 var(--page-padding-x); margin: 1rem auto; }
.hero-top { flex-direction: column; align-items: flex-start; }
.btn-workspace-hero { width: 100%; justify-content: center; }
.projects-grid { grid-template-columns: 1fr; }
File diff suppressed because it is too large Load Diff
+35
View File
@@ -1,14 +1,18 @@
<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[]>([])
@@ -20,6 +24,8 @@ 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
@@ -79,6 +85,19 @@ function formatRelativeDate(iso: string | null): string {
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()
@@ -145,6 +164,13 @@ onMounted(async () => {
@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>
@@ -335,6 +361,15 @@ a.news-card-title:hover {
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;
+98 -13
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { NoteType } from "@/types/note";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
@@ -31,6 +32,8 @@ const body = ref("");
const tags = ref<string[]>([]);
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const noteType = ref<NoteType>("note");
const entityMeta = reactive<Record<string, string>>({});
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -186,6 +189,8 @@ let savedBody = "";
let savedTags: string[] = [];
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
let savedNoteType: NoteType = "note";
let savedEntityMeta: Record<string, string> = {};
function markDirty() {
dirty.value =
@@ -193,7 +198,9 @@ function markDirty() {
body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId;
milestoneId.value !== savedMilestoneId ||
noteType.value !== savedNoteType ||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
}
function onBodyUpdate(newVal: string) {
@@ -210,24 +217,34 @@ onMounted(async () => {
tags.value = [...(store.currentNote.tags || [])];
projectId.value = store.currentNote.project_id ?? null;
milestoneId.value = store.currentNote.milestone_id ?? null;
noteType.value = (store.currentNote.note_type as NoteType) || "note";
Object.assign(entityMeta, store.currentNote.metadata || {});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedEntityMeta = { ...entityMeta };
}
// Restore pending draft if any
try {
const draft = await apiGet<NoteDraft>(`/api/notes/${noteId.value}/draft`);
if (draft) assist.loadDraft(draft);
} catch {
// No draft — normal
} else {
// New note: read type from query param
const qt = route.query.type as string | undefined;
if (qt && ["note", "person", "place", "list"].includes(qt)) {
noteType.value = qt as NoteType;
}
// Initial link suggestions
fetchLinkSuggestions();
}
// Restore pending draft if any
try {
const draft = await apiGet<NoteDraft>(`/api/notes/${noteId.value}/draft`);
if (draft) assist.loadDraft(draft);
} catch {
// No draft — normal
}
// Initial link suggestions
fetchLinkSuggestions();
});
async function save() {
@@ -241,12 +258,16 @@ async function save() {
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
note_type: noteType.value,
metadata: { ...entityMeta },
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedEntityMeta = { ...entityMeta };
dirty.value = false;
toast.show("Note saved");
} else {
@@ -256,6 +277,8 @@ async function save() {
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
note_type: noteType.value,
metadata: { ...entityMeta },
});
dirty.value = false;
toast.show("Note created");
@@ -295,12 +318,15 @@ async function doAutoSave() {
await store.updateNote(noteId.value!, {
title: title.value, body: body.value, tags: tags.value,
project_id: projectId.value, milestone_id: milestoneId.value,
} as Record<string, unknown>);
note_type: noteType.value, metadata: { ...entityMeta },
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedEntityMeta = { ...entityMeta };
dirty.value = false;
toast.show("Auto-saved");
} catch {
@@ -439,6 +465,49 @@ onUnmounted(() => assist.clearSelection());
</template>
</div>
<!-- Note type -->
<div class="sb-field">
<label class="sb-label">Type</label>
<select v-model="noteType" class="sb-select" @change="markDirty">
<option value="note">Note</option>
<option value="person">Person</option>
<option value="place">Place</option>
<option value="list">List</option>
</select>
</div>
<!-- Person metadata -->
<template v-if="noteType === 'person'">
<div class="sb-field">
<label class="sb-label">Relationship</label>
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Email</label>
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
</template>
<!-- Place metadata -->
<template v-if="noteType === 'place'">
<div class="sb-field">
<label class="sb-label">Address</label>
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Hours</label>
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 95" @input="markDirty" />
</div>
</template>
<!-- Link Suggestions -->
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
<div class="sb-label-row">
@@ -649,6 +718,22 @@ onUnmounted(() => assist.clearSelection());
flex-direction: column;
}
.sb-select, .sb-input {
width: 100%;
padding: 5px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
color: var(--color-text);
font-size: 0.82rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.sb-select:focus, .sb-input:focus {
border-color: var(--color-primary, #6366f1);
}
/* Tag suggest row inside sidebar */
.tag-suggest-row {
display: flex;
+41 -2
View File
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime";
import { apiPost, apiGet } from "@/api/client";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
@@ -79,11 +79,48 @@ watch(() => route.params.id, (newId) => {
if (newId) loadNote(Number(newId));
});
const isListNote = computed(() => {
const body = store.currentNote?.body ?? "";
return /^- \[[ xX]\] /m.test(body);
});
const renderedBody = computed(() => {
if (!store.currentNote) return "";
return renderMarkdown(store.currentNote.body);
return renderMarkdown(store.currentNote.body, { interactiveCheckboxes: isListNote.value });
});
async function onBodyChange(e: Event) {
const target = e.target as HTMLInputElement;
if (target.type !== "checkbox" || !store.currentNote) return;
const index = parseInt(target.dataset.taskIndex ?? "", 10);
if (isNaN(index)) return;
let taskIdx = 0;
const newBody = store.currentNote.body.split("\n").map(line => {
const stripped = line.trimStart();
if (stripped.startsWith("- [ ] ") || stripped.startsWith("- [x] ") || stripped.startsWith("- [X] ")) {
if (taskIdx === index) {
const indent = line.length - stripped.length;
const wasChecked = !stripped.startsWith("- [ ] ");
taskIdx++;
return " ".repeat(indent) + (wasChecked ? "- [ ] " : "- [x] ") + stripped.slice(6);
}
taskIdx++;
}
return line;
}).join("\n");
// Optimistic update so the checkbox state doesn't snap back
store.currentNote.body = newBody;
try {
await apiPatch(`/api/notes/${store.currentNote.id}`, { body: newBody });
} catch {
await store.fetchNote(store.currentNote.id);
}
}
async function onBodyClick(e: MouseEvent) {
const target = e.target as HTMLElement;
@@ -220,8 +257,10 @@ async function convertToTask() {
</div>
<div
class="body prose"
:class="{ 'prose--checklist': isListNote }"
v-html="renderedBody"
@click="onBodyClick"
@change="onBodyChange"
></div>
<div v-if="backlinks.length" class="backlinks">
+2 -2
View File
@@ -192,9 +192,9 @@ function onOffsetUpdate(offset: number) {
<style scoped>
.notes-list {
max-width: 1200px;
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 1rem;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.header {
+7 -14
View File
@@ -16,11 +16,15 @@ interface MilestoneSummary {
interface Project {
id: number;
user_id: number;
title: string;
description: string | null;
goal: string | null;
status: "active" | "completed" | "archived";
color: string | null;
auto_summary: string | null;
permission?: string;
is_shared?: boolean;
created_at: string;
updated_at: string;
summary?: {
@@ -54,19 +58,8 @@ async function loadProjects() {
loading.value = true;
error.value = null;
try {
const data = await apiGet<{ projects: Project[] }>("/api/projects");
const data = await apiGet<{ projects: Project[] }>("/api/projects?include_summary=true");
projects.value = data.projects;
// Fetch summaries (including milestone_summary) in parallel
await Promise.allSettled(
projects.value.map(async (p) => {
try {
const full = await apiGet<Project>(`/api/projects/${p.id}`);
p.summary = full.summary;
} catch {
// non-fatal
}
})
);
} catch {
error.value = "Failed to load projects.";
} finally {
@@ -253,9 +246,9 @@ function truncate(text: string | null, max = 120): string {
<style scoped>
.projects-list {
max-width: 1200px;
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 1rem;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
+5 -2
View File
@@ -19,11 +19,14 @@ interface Milestone {
interface Project {
id: number;
user_id: number;
title: string;
description: string | null;
goal: string | null;
status: "active" | "completed" | "archived";
color: string | null;
auto_summary: string | null;
permission?: string;
created_at: string;
updated_at: string;
summary?: {
@@ -639,9 +642,9 @@ async function confirmDelete() {
<style scoped>
/* ── Layout ─────────────────────────────────────────────────── */
.project-view {
max-width: 1200px;
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 1rem;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -333,7 +333,7 @@ async function save() {
savedParentId = parentId.value;
dirty.value = false;
toast.show("Task saved");
window.location.reload();
router.push(`/tasks/${taskId.value}`);
} else {
const task = await store.createTask(data);
dirty.value = false;
+2 -2
View File
@@ -353,9 +353,9 @@ function toggleGroup(key: string) {
<style scoped>
.tasks-list {
max-width: 1200px;
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 1rem;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.header {
+45 -394
View File
@@ -3,18 +3,13 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useRoute } from "vue-router";
import { apiGet } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
import ChatPanel from "@/components/ChatPanel.vue";
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
const route = useRoute();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const toast = useToastStore();
const projectId = computed(() => Number(route.params.projectId));
@@ -26,10 +21,9 @@ interface Project {
}
const project = ref<Project | null>(null);
const messageInput = ref("");
const messagesEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
const activeNoteId = ref<number | null>(null);
let workspaceConvId: number | null = null;
let isNewConv = false;
@@ -64,7 +58,7 @@ const gridColumns = computed(() => {
].join(" ");
});
// SSE watcher
// SSE watcher — auto-load notes/tasks when tool calls succeed
const processedCount = ref(0);
watch(
@@ -78,6 +72,7 @@ watch(
tc.result?.data?.id
) {
activeNoteId.value = tc.result.data.id as number;
noteEditorRef.value?.reload();
}
if (
["create_task", "update_task", "create_milestone", "update_milestone"].includes(tc.function) &&
@@ -94,29 +89,10 @@ watch(
watch(
() => chatStore.streaming,
(s) => {
if (!s) {
processedCount.value = 0;
scrollToBottom();
}
if (!s) processedCount.value = 0;
}
);
const streamingRendered = computed(() => {
if (!chatStore.streamingContent) return "";
return renderMarkdown(chatStore.streamingContent);
});
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
}
});
}
watch(() => chatStore.streamingContent, scrollToBottom);
watch(() => chatStore.currentConversation?.messages.length, scrollToBottom);
function togglePanel(panel: keyof typeof panelOpen.value) {
const open = panelOpen.value;
const openCount = [open.tasks, open.chat, open.notes].filter(Boolean).length;
@@ -126,54 +102,7 @@ function togglePanel(panel: keyof typeof panelOpen.value) {
}
function prefill(text: string) {
messageInput.value = text;
nextTick(() => inputEl.value?.focus());
}
async function sendMessage() {
const content = messageInput.value.trim();
if (!content) return;
messageInput.value = "";
resetTextareaHeight();
await chatStore.sendMessage(
content,
undefined,
undefined,
true,
undefined,
undefined,
projectId.value,
projectId.value,
);
scrollToBottom();
nextTick(() => inputEl.value?.focus());
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
if (e.key === "Escape") {
// Prevent App.vue from navigating home when textarea is focused
e.stopPropagation();
inputEl.value?.blur();
}
}
function autoResize() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 150) + "px";
}
function resetTextareaHeight() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
chatPanelRef.value?.prefill(text);
}
onMounted(async () => {
@@ -214,7 +143,7 @@ onMounted(async () => {
localStorage.setItem(key, String(conv.id));
}
nextTick(() => inputEl.value?.focus());
nextTick(() => chatPanelRef.value?.focus());
});
onUnmounted(async () => {
@@ -279,114 +208,30 @@ onUnmounted(async () => {
</div>
<!-- Center: Chat -->
<div v-show="panelOpen.chat" class="ws-panel ws-chat-panel">
<div class="chat-messages" ref="messagesEl">
<template v-if="chatStore.currentConversation">
<ChatMessage
v-for="msg in chatStore.currentConversation.messages"
:key="msg.id"
:message="msg"
/>
</template>
<!-- Streaming bubble -->
<div v-if="chatStore.streaming" class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div v-if="chatStore.streamingToolCalls.length" class="streaming-tool-calls">
<ToolCallCard
v-for="(tc, i) in chatStore.streamingToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<ToolConfirmCard
v-if="chatStore.streamingPendingTool"
:pending-tool="chatStore.streamingPendingTool"
@accept="chatStore.confirmTool(true)"
@decline="chatStore.confirmTool(false)"
/>
<div v-if="chatStore.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ chatStore.streamingStatus }}
</div>
<details
v-if="chatStore.streamingThinking"
class="thinking-block"
:open="!chatStore.streamingContent"
>
<summary class="thinking-summary">Reasoning</summary>
<pre class="thinking-text">{{ chatStore.streamingThinking }}</pre>
</details>
<div class="message-content prose" v-html="streamingRendered"></div>
<span
v-if="!chatStore.streamingStatus && !chatStore.streamingThinking"
class="typing-indicator"
></span>
</div>
</div>
<!-- Queued messages shown as pending bubbles -->
<template v-if="chatStore.queuedMessages.length">
<div v-show="panelOpen.chat" class="ws-panel ws-panel-chat">
<Transition name="panel-fade">
<div v-if="panelOpen.chat" class="panel-inner panel-inner-chat">
<!-- Quick chips (shown when conversation is empty) -->
<div
v-for="(q, i) in chatStore.queuedMessages"
:key="`queued-${i}`"
class="ws-message role-user queued-message"
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
class="empty-chat-prompt"
>
<div class="message-bubble queued-bubble">
<div class="queued-badge">Queued</div>
<div class="message-content">{{ q.content }}</div>
<p class="empty-hint">What would you like to work on?</p>
<div class="quick-chips">
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
<button class="quick-chip" @click="prefill('Add tasks for ')"> Add tasks</button>
</div>
</div>
<div class="queued-clear-row">
<button class="queued-clear-btn" @click="chatStore.clearQueue()" aria-label="Cancel queued messages">
Cancel {{ chatStore.queuedMessages.length }} queued
</button>
</div>
</template>
<div
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
class="empty-chat-prompt"
>
<p class="empty-hint">What would you like to work on?</p>
<div class="quick-chips">
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
<button class="quick-chip" @click="prefill('Add tasks for ')"> Add tasks</button>
</div>
<ChatPanel
ref="chatPanelRef"
variant="full"
:projectId="projectId"
placeholder="Message the agent… (Enter to send)"
class="ws-chat-panel"
/>
</div>
</div>
<div class="chat-input-area">
<textarea
ref="inputEl"
v-model="messageInput"
class="chat-input"
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent… (Enter to send)'"
rows="1"
@keydown="onInputKeydown"
@input="autoResize"
></textarea>
<button
v-if="chatStore.streaming"
class="btn-abort"
title="Stop generation"
@click="chatStore.cancelGeneration()"
>
Stop
</button>
<button
v-else
class="btn-send"
:disabled="!messageInput.trim()"
@click="sendMessage"
>
Send
</button>
</div>
</Transition>
</div>
<!-- Right: Note Editor -->
@@ -395,6 +240,7 @@ onUnmounted(async () => {
<div v-if="panelOpen.notes" class="panel-inner">
<WorkspaceNoteEditor
v-if="project"
ref="noteEditorRef"
:project-id="project.id"
:active-note-id="activeNoteId"
/>
@@ -499,25 +345,31 @@ onUnmounted(async () => {
}
/* Chat panel */
.ws-chat-panel {
display: flex;
flex-direction: column;
.ws-panel-chat {
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.panel-inner-chat {
position: relative;
}
.ws-chat-panel {
flex: 1;
min-height: 0;
}
.empty-chat-prompt {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: 2rem 1rem;
pointer-events: none;
z-index: 1;
}
.empty-hint {
margin: 0 0 1rem;
@@ -529,6 +381,7 @@ onUnmounted(async () => {
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
pointer-events: auto;
}
.quick-chip {
background: var(--color-surface);
@@ -546,206 +399,4 @@ onUnmounted(async () => {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
}
.chat-input-area {
display: flex;
gap: 0.5rem;
padding: 0.6rem;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.chat-input {
flex: 1;
resize: none;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.4rem 0.6rem;
font-size: 0.875rem;
font-family: inherit;
color: var(--color-text);
line-height: 1.5;
overflow-y: hidden;
}
.chat-input:focus {
outline: none;
border-color: var(--color-primary);
}
.btn-send {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.9rem;
font-size: 0.875rem;
cursor: pointer;
align-self: flex-end;
}
.btn-send:disabled {
opacity: 0.4;
cursor: default;
}
.btn-abort {
background: none;
color: var(--color-danger, #e74c3c);
border: 1px solid var(--color-danger, #e74c3c);
border-radius: 6px;
padding: 0.4rem 0.9rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
align-self: flex-end;
white-space: nowrap;
}
.btn-abort:hover {
background: var(--color-danger, #e74c3c);
color: #fff;
}
/* Streaming indicators (mirror ChatView) */
.chat-message {
display: flex;
}
.role-assistant {
justify-content: flex-start;
}
.message-bubble {
max-width: 85%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.65rem 0.85rem;
}
.message-header {
margin-bottom: 0.3rem;
}
.role-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.streaming-tool-calls {
margin-bottom: 0.4rem;
}
.streaming-status-line {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--color-text-muted);
margin-bottom: 0.3rem;
}
.streaming-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
animation: pulse 1s infinite;
}
.typing-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.message-content :deep(p) { margin: 0 0 0.5em; }
.message-content :deep(p:last-child) { margin-bottom: 0; }
.thinking-block {
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.thinking-summary {
padding: 0.25rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 0.3rem;
background: var(--color-bg-secondary);
}
.thinking-summary::-webkit-details-marker { display: none; }
.thinking-summary::before {
content: "▶";
font-size: 0.6rem;
transition: transform 0.15s;
}
details[open] .thinking-summary::before {
transform: rotate(90deg);
}
.thinking-text {
margin: 0;
padding: 0.5rem;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
.ws-message.role-user {
display: flex;
justify-content: flex-end;
}
.queued-bubble {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 18px;
border-bottom-right-radius: 4px;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
opacity: 0.45;
font-size: 0.95rem;
line-height: 1.55;
word-break: break-word;
}
.queued-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 0.2rem;
}
.queued-clear-row {
display: flex;
justify-content: flex-end;
padding-right: 0.5rem;
}
.queued-clear-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.78rem;
padding: 0.2rem 0.6rem;
font-family: inherit;
}
.queued-clear-btn:hover {
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
</style>
+7
View File
@@ -19,6 +19,8 @@ dependencies = [
"caldav>=1.3",
"icalendar>=5.0",
"feedparser>=6.0",
"html2text>=2024.2",
"trafilatura>=1.12",
"APScheduler>=3.10,<4.0",
"pywebpush>=2.0",
]
@@ -29,6 +31,11 @@ dev = [
"pytest-asyncio>=0.23",
"ruff>=0.6",
]
voice = [
"faster-whisper>=1.0",
"kokoro>=0.9",
"soundfile>=0.12",
]
[tool.setuptools.packages.find]
where = ["src"]
+109 -25
View File
@@ -31,6 +31,9 @@ from fabledassistant.routes.users import users_bp
from fabledassistant.routes.api_keys import api_keys_bp
from fabledassistant.routes.events import events_bp
from fabledassistant.routes.search import search_bp
from fabledassistant.routes.voice import voice_bp
from fabledassistant.routes.profile import profile_bp
from fabledassistant.routes.knowledge import knowledge_bp
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
@@ -62,6 +65,13 @@ def create_app() -> Quart:
"Set SECRET_KEY or SECRET_KEY_FILE for production use."
)
if not Config.TRUST_PROXY_HEADERS:
logger.warning(
"TRUST_PROXY_HEADERS is not set. If this instance is behind a reverse proxy "
"(nginx, Caddy, Traefik) set TRUST_PROXY_HEADERS=true so rate limiting uses "
"real client IPs rather than the proxy IP."
)
app.register_blueprint(admin_bp)
app.register_blueprint(api)
app.register_blueprint(auth_bp)
@@ -85,6 +95,9 @@ def create_app() -> Quart:
app.register_blueprint(api_keys_bp)
app.register_blueprint(events_bp)
app.register_blueprint(search_bp)
app.register_blueprint(voice_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(knowledge_bp)
@app.before_request
async def before_request():
@@ -160,64 +173,112 @@ def create_app() -> Quart:
async with httpx.AsyncClient(timeout=300.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/generate",
json={"model": model, "prompt": "", "keep_alive": "30m"},
json={"model": model, "prompt": "", "keep_alive": "2h"},
)
logger.info("Warmed model '%s' into VRAM", model)
except Exception:
logger.warning("Failed to warm model '%s'", model, exc_info=True)
async def _prime_kv_cache(user_id: int, model: str) -> None:
"""Send a minimal chat request to prime Ollama's KV cache with the user's system prompt.
This ensures the next real user message only needs to process its own tokens
rather than the full ~4,650-token system prompt, cutting TTFT from ~25s to <1s.
The num_ctx must match what real requests will use so Ollama doesn't reload.
"""
try:
from fabledassistant.services.llm import build_context, pick_num_ctx
messages, _ = await build_context(
user_id=user_id,
history=[],
current_note_id=None,
user_message=" ",
)
num_ctx = pick_num_ctx(messages)
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"options": {"num_predict": 1, "num_ctx": num_ctx},
"keep_alive": "2h",
},
)
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
except Exception:
logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True)
async def _warm_user_models() -> None:
"""
Warm whichever chat model(s) users have selected in Settings.
Pull any user-configured models that are missing from Ollama, then warm
them and prime the KV cache with each user's system prompt.
Only warms models that are already installed in Ollama — never auto-pulls.
Handles both default_model (chat) and background_model user overrides.
Falls back silently if no user preferences exist or Ollama is unreachable.
"""
from sqlalchemy import select as sa_select, distinct
from sqlalchemy import select as sa_select
from fabledassistant.models import async_session
from fabledassistant.models.setting import Setting
# 1. Collect all distinct default_model values users have saved.
# 1. Collect all user model preferences (both chat and background).
try:
async with async_session() as session:
rows = await session.execute(
sa_select(distinct(Setting.value)).where(
Setting.key == "default_model",
sa_select(Setting.user_id, Setting.key, Setting.value).where(
Setting.key.in_(["default_model", "background_model"]),
Setting.value.isnot(None),
Setting.value != "",
)
)
user_models: set[str] = {r for (r,) in rows}
settings_rows: list[tuple[int, str, str]] = list(rows)
except Exception:
logger.debug("Could not read user model preferences from DB", exc_info=True)
return
if not user_models:
if not settings_rows:
logger.debug("No user model preferences found; skipping warm-up")
return
# 2. Ask Ollama which models are currently installed.
# 2. Build the set of unique models to ensure, and the list of
# (user_id, chat_model) pairs for KV-cache priming.
all_models: set[str] = set()
user_chat_models: list[tuple[int, str]] = []
for user_id_val, key, model in settings_rows:
all_models.add(model)
if key == "default_model":
user_chat_models.append((user_id_val, model))
# 3. Ask Ollama which models are currently installed.
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
raw_installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
installed: set[str] = raw_installed | {
n.removesuffix(":latest") for n in raw_installed if n.endswith(":latest")
}
except Exception:
logger.debug("Could not reach Ollama to check installed models", exc_info=True)
return
# 3. Warm only the intersection (installed AND user-preferred).
for model in user_models:
base = model.removesuffix(":latest")
if model in installed or f"{base}:latest" in installed or base in installed:
await _warm_model(model)
else:
logger.info(
"User-preferred model '%s' is not installed; skipping warm-up "
"(install it via Settings → Models to enable auto-warm)",
model,
)
# 4. Pull any user-configured models that are missing.
for model in all_models:
if model not in installed:
logger.info("User-configured model '%s' not installed; pulling...", model)
await _pull_model(model)
installed.add(model)
# 5. Warm each unique chat model, then prime KV cache per user.
warmed: set[str] = set()
for user_id_val, model in user_chat_models:
if model in installed:
if model not in warmed:
await _warm_model(model)
warmed.add(model)
await _prime_kv_cache(user_id_val, model)
async def _pull_model(model: str, warm: bool = False) -> None:
try:
@@ -232,10 +293,11 @@ def create_app() -> Quart:
if warm:
await _warm_model(model)
# Warm user-preferred chat models that are already installed.
# Also ensure the embedding model is pulled (no warm needed).
asyncio.create_task(_warm_user_models())
# Ensure system-default models are present, then pull/warm user-configured ones.
asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=True))
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
asyncio.create_task(_pull_model(Config.OLLAMA_BACKGROUND_MODEL, warm=False))
asyncio.create_task(_warm_user_models())
# After models are pulled, backfill embeddings for existing notes.
# Runs in the background so it never blocks the server from accepting requests.
@@ -250,6 +312,16 @@ 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())
@@ -257,10 +329,22 @@ def create_app() -> Quart:
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
await start_briefing_scheduler(asyncio.get_running_loop())
# Start event scheduler (reminders + CalDAV pull sync)
from fabledassistant.services.event_scheduler import start_event_scheduler
start_event_scheduler(asyncio.get_running_loop())
# Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var)
from fabledassistant.services.stt import load_stt_model
from fabledassistant.services.tts import load_tts_model
asyncio.create_task(load_stt_model())
asyncio.create_task(load_tts_model())
@app.after_serving
async def shutdown():
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
stop_briefing_scheduler()
from fabledassistant.services.event_scheduler import stop_event_scheduler
stop_event_scheduler()
@app.route("/")
async def serve_index():
+25 -3
View File
@@ -24,9 +24,14 @@ class Config:
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
# KV cache context window for generation. Higher = more RAM usage but longer inputs/outputs.
# 131072 is the practical maximum for most models. Lower this on RAM-constrained hosts.
OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "65536"))
# Lightweight model for background tasks (title generation, tag suggestions,
# project summaries, RSS classification). Using a separate model keeps the
# main model's KV cache intact between user messages, enabling prefix cache hits.
OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "qwen2.5:0.5b")
# KV cache context window for generation. Keep this as small as practical —
# a larger context forces more KV cache into CPU RAM, drastically slowing prefill.
# 16384 covers ~30+ message conversations with our system prompt comfortably.
OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "16384"))
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
@@ -66,6 +71,12 @@ class Config:
VAPID_PUBLIC_KEY: str = os.environ.get("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_SUB: str = os.environ.get("VAPID_CLAIMS_SUB", "mailto:admin@fabledassistant.local")
# Voice (Speech-to-Speech) feature
VOICE_ENABLED: bool = os.environ.get("VOICE_ENABLED", "").lower() in ("1", "true", "yes")
STT_BACKEND: str = os.environ.get("STT_BACKEND", "faster-whisper")
STT_MODEL: str = os.environ.get("STT_MODEL", "base.en")
TTS_BACKEND: str = os.environ.get("TTS_BACKEND", "kokoro")
@classmethod
def oidc_enabled(cls) -> bool:
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
@@ -88,5 +99,16 @@ class Config:
errors.append(f"SMTP_PORT={cls.SMTP_PORT} must be between 1 and 65535")
if cls.oidc_enabled() and not cls.BASE_URL.startswith(("http://", "https://")):
errors.append(f"BASE_URL='{cls.BASE_URL}' must start with http:// or https:// when OIDC is enabled")
if cls.SECRET_KEY == "dev-secret-change-me" and cls.SECURE_COOKIES:
errors.append(
"SECRET_KEY is set to the insecure default but SECURE_COOKIES=true indicates "
"a production deployment. Set SECRET_KEY or SECRET_KEY_FILE before starting."
)
_valid_stt_models = {"tiny.en", "base.en", "small.en", "medium.en"}
if cls.VOICE_ENABLED and cls.STT_MODEL not in _valid_stt_models:
errors.append(
f"STT_MODEL='{cls.STT_MODEL}' is not supported. "
f"Valid values: {', '.join(sorted(_valid_stt_models))}"
)
if errors:
raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors))
+2
View File
@@ -41,3 +41,5 @@ 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
+3
View File
@@ -27,6 +27,8 @@ class Event(Base):
caldav_uid: Mapped[str] = mapped_column(Text, default="")
color: Mapped[str] = mapped_column(Text, default="")
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
reminder_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
reminder_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
@@ -51,6 +53,7 @@ class Event(Base):
"location": self.location,
"color": self.color,
"recurrence": self.recurrence,
"reminder_minutes": self.reminder_minutes,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+13
View File
@@ -51,6 +51,11 @@ class Note(Base, TimestampMixin):
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Entity type — 'note' (default), 'person', 'place', 'list'
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
# Structured metadata for entity types (person/place/list)
# Named 'entity_meta' to avoid collision with SQLAlchemy's reserved 'metadata' attribute
entity_meta: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
__table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"),
@@ -59,12 +64,18 @@ class Note(Base, TimestampMixin):
Index("ix_notes_user_id", "user_id"),
Index("ix_notes_project_id", "project_id"),
Index("ix_notes_milestone_id", "milestone_id"),
Index("ix_notes_note_type", "note_type"),
)
@property
def is_task(self) -> bool:
return self.status is not None
@property
def entity_type(self) -> str:
"""Normalised type: 'note', 'person', 'place', or 'list'."""
return self.note_type or "note"
def to_dict(self) -> dict:
return {
"id": self.id,
@@ -86,6 +97,8 @@ class Note(Base, TimestampMixin):
else None
),
"is_task": self.is_task,
"note_type": self.entity_type,
"metadata": self.entity_meta or {},
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+2
View File
@@ -29,11 +29,13 @@ class Project(Base, TimestampMixin):
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"title": self.title,
"description": self.description,
"goal": self.goal,
"status": self.status,
"color": self.color,
"auto_summary": self.auto_summary,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -0,0 +1,25 @@
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),
)
@@ -0,0 +1,55 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class UserProfile(Base, TimestampMixin):
__tablename__ = "user_profiles"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True
)
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
job_title: Mapped[str | None] = mapped_column(Text, nullable=True)
industry: Mapped[str | None] = mapped_column(Text, nullable=True)
# novice / intermediate / expert — calibrates explanation depth
expertise_level: Mapped[str | None] = mapped_column(Text, nullable=True)
# concise / balanced / detailed
response_style: Mapped[str | None] = mapped_column(Text, nullable=True)
# casual / professional / technical
tone: Mapped[str | None] = mapped_column(Text, nullable=True)
interests: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
# {days: ["Mon","Tue",...], start: "09:00", end: "17:00"}
work_schedule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# LLM-consolidated summary of learned preferences
learned_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
# [{date: "YYYY-MM-DD", bullets: "..."}, ...]
observations_raw: Mapped[list | None] = mapped_column(JSONB, nullable=True)
observations_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
def to_dict(self) -> dict:
return {
"display_name": self.display_name or "",
"job_title": self.job_title or "",
"industry": self.industry or "",
"expertise_level": self.expertise_level or "intermediate",
"response_style": self.response_style or "balanced",
"tone": self.tone or "casual",
"interests": self.interests or [],
"work_schedule": self.work_schedule or {},
"learned_summary": self.learned_summary or "",
"observations_count": len(self.observations_raw or []),
"observations_updated_at": (
self.observations_updated_at.isoformat()
if self.observations_updated_at
else None
),
}
+9
View File
@@ -1,3 +1,12 @@
"""In-process sliding-window rate limiter.
IMPORTANT — deployment note:
Rate limit counters are stored in memory and are lost on process restart.
When deployed behind a reverse proxy (nginx, Caddy, Traefik) you MUST set
TRUST_PROXY_HEADERS=true so that the real client IP is used as the bucket key
rather than the proxy's IP (which would cause all users to share one bucket).
"""
import asyncio
import time
from collections import defaultdict
+43
View File
@@ -19,6 +19,7 @@ from fabledassistant.services.backup import (
restore_full_backup,
)
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from fabledassistant.services.voice_config import get_voice_config
from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
from fabledassistant.services.notifications import send_invitation_email
from fabledassistant.services.settings import set_setting, set_settings_batch
@@ -194,12 +195,54 @@ async def get_base_url_setting():
async def update_base_url():
data = await request.get_json()
url = (data.get("base_url") or "").strip().rstrip("/")
if url:
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
return jsonify({"error": "Base URL must use http or https"}), 400
uid = get_current_user_id()
await set_setting(uid, "base_url", url)
await log_audit("base_url_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"base_url": url})
return jsonify({"status": "ok"})
@admin_bp.route("/voice", methods=["GET"])
@admin_required
async def get_voice_config_route():
config = await get_voice_config()
return jsonify(config)
@admin_bp.route("/voice", methods=["PUT"])
@admin_required
async def update_voice_config():
data = await request.get_json()
uid = get_current_user_id()
valid_models = {"tiny.en", "base.en", "small.en", "medium.en"}
settings: dict[str, str] = {}
if "voice_enabled" in data:
settings["voice_enabled"] = "true" if data["voice_enabled"] else "false"
if "voice_stt_model" in data:
model = str(data["voice_stt_model"])
if model not in valid_models:
return jsonify({"error": f"Invalid STT model. Choose from: {', '.join(sorted(valid_models))}"}), 400
settings["voice_stt_model"] = model
if settings:
await set_settings_batch(uid, settings)
await log_audit("voice_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details=settings)
return jsonify({"status": "ok"})
@admin_bp.route("/voice/reload", methods=["POST"])
@admin_required
async def reload_voice_models():
"""Reload STT and TTS models in the background without a server restart."""
from fabledassistant.services.stt import reload_stt_model
from fabledassistant.services.tts import reload_tts_model
asyncio.create_task(reload_stt_model())
asyncio.create_task(reload_tts_model())
return jsonify({"status": "loading"})
@admin_bp.route("/invitations", methods=["POST"])
@admin_required
async def create_invite():
+4 -1
View File
@@ -359,7 +359,10 @@ async def oauth_callback():
return redirect("/login?error=oauth")
sub = claims.get("sub", "")
email = claims.get("email", "")
# Only trust the email claim for account linking if the provider has verified it.
# An unverified email could be used to hijack an existing local account.
email_verified = claims.get("email_verified", False)
email = claims.get("email", "") if email_verified else ""
preferred_username = claims.get("preferred_username", "")
if not sub:
+118 -2
View File
@@ -10,7 +10,7 @@ from sqlalchemy import select
from fabledassistant.auth import login_required
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.models.rss_feed import RssFeed
from fabledassistant.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 (
@@ -18,6 +18,9 @@ from fabledassistant.services.briefing_conversations import (
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__)
@@ -71,6 +74,12 @@ async def add_feed():
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "url required"}), 400
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
return jsonify({"error": "Feed URL must use http or https"}), 400
from fabledassistant.services.llm import _is_private_url
if _is_private_url(url):
return jsonify({"error": "Feed URL must not point to an internal address"}), 400
category = data.get("category") or None
async with async_session() as session:
@@ -236,12 +245,30 @@ async def get_conversation_messages(conv_id: int):
@briefing_bp.route("/trigger", methods=["POST"])
@_REQUIRE
async def manual_trigger():
"""Dev/admin endpoint to manually trigger a briefing compilation."""
"""Manually trigger a briefing compilation, including a fresh data refresh."""
data = await request.get_json() or {}
slot = data.get("slot", "compilation")
if slot not in ("compilation", "morning", "midday", "afternoon"):
return jsonify({"error": "invalid slot"}), 400
# Refresh external data first (mirrors what the scheduler does)
try:
from fabledassistant.services.rss import refresh_all_feeds
config_raw = await get_setting(g.user.id, "briefing_config", "{}")
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
await refresh_all_feeds(g.user.id)
for key, loc in config.get("locations", {}).items():
if loc.get("lat") and loc.get("lon"):
await weather_svc.refresh_location_cache(
user_id=g.user.id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Pre-trigger refresh failed for user %d", g.user.id, exc_info=True)
from fabledassistant.services.briefing_pipeline import run_compilation
model = await get_setting(g.user.id, "default_model", "")
@@ -382,6 +409,7 @@ async def list_news():
"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"],
@@ -390,3 +418,91 @@ async def list_news():
for r in rows
]
return jsonify({"items": items, "offset": offset, "limit": limit})
# ── Article Discuss ────────────────────────────────────────────────────────────
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_article(item_id: int):
"""Inject article content as a synthetic tool exchange and trigger generation."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify item belongs to user via feed ownership
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssItem.id == item_id, RssFeed.user_id == uid)
)
item = result.scalars().first()
if item is None:
return jsonify({"error": "Not found"}), 404
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
# Reject if generation already running
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
from fabledassistant.services.rss import _fetch_full_article
article_content = await _fetch_full_article(item.url) or item.content or ""
# Store synthetic assistant message with read_article tool result
synthetic_tool_calls = [{
"function": "read_article",
"arguments": {"url": item.url},
"result": {
"success": True,
"type": "article_content",
"url": item.url,
"content": article_content,
"truncated": False,
},
}]
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
# Store user message
await add_message(conv_id, "user", "Please summarize and discuss this article.")
# Reload conversation with fresh messages to build history
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", "") or ""
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
"Please summarize and discuss this article.",
think=True,
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
+67 -15
View File
@@ -5,13 +5,12 @@ import logging
import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
bulk_delete_conversations,
cleanup_old_conversations,
create_conversation,
delete_conversation,
get_conversation,
@@ -40,14 +39,6 @@ async def list_conversations_route():
uid = get_current_user_id()
limit, offset = parse_pagination()
conv_type = request.args.get("type", "chat")
# Apply retention policy before returning list
retention_str = await get_setting(uid, "chat_retention_days", "90")
try:
retention_days = int(retention_str)
except (ValueError, TypeError):
retention_days = 90
if retention_days > 0:
await cleanup_old_conversations(uid, retention_days)
conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type)
return jsonify({
"conversations": conversations,
@@ -76,7 +67,7 @@ async def create_conversation_route():
model = data.get("model", Config.OLLAMA_MODEL)
conversation_type = data.get("conversation_type", "chat")
# Only allow known types to prevent accidental misuse
if conversation_type not in ("chat", "mcp"):
if conversation_type not in ("chat", "mcp", "voice"):
conversation_type = "chat"
conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type)
return jsonify(conv.to_dict()), 201
@@ -171,8 +162,19 @@ async def send_message_route(conv_id: int):
# Build history from existing messages (excluding system and the placeholder)
history = []
for msg in conv.messages:
if msg.role != "system":
history.append({"role": msg.role, "content": msg.content})
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", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
@@ -187,6 +189,7 @@ async def send_message_route(conv_id: int):
rag_project_id=effective_rag_project_id,
workspace_project_id=workspace_project_id,
user_timezone=user_timezone,
voice_mode=(conv.conversation_type == "voice"),
))
return jsonify({
@@ -439,7 +442,7 @@ async def list_models_route():
@chat_bp.route("/models/pull", methods=["POST"])
@login_required
@admin_required
async def pull_model_route():
"""Pull a model from Ollama, streaming progress via SSE."""
data = await request.get_json()
@@ -478,7 +481,7 @@ async def pull_model_route():
@chat_bp.route("/models/delete", methods=["POST"])
@login_required
@admin_required
async def delete_model_route():
"""Delete a model from Ollama."""
data = await request.get_json()
@@ -501,3 +504,52 @@ async def delete_model_route():
except Exception as e:
logger.warning("Failed to delete model %s: %s", model_name, e)
return jsonify({"error": str(e)}), 500
@chat_bp.route("/from-article/<int:item_id>", methods=["POST"])
@login_required
async def create_conversation_from_article(item_id: int):
"""Create a chat conversation seeded with an RSS article's content."""
from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Message
from fabledassistant.models.rss_feed import RssItem, RssFeed
uid = get_current_user_id()
async with _async_session() as session:
result = await session.execute(
_select(RssItem, RssFeed.title.label("feed_title"))
.join(RssFeed, RssItem.feed_id == RssFeed.id)
.where(RssItem.id == item_id, RssFeed.user_id == uid)
)
row = result.first()
if row is None:
return jsonify({"error": "Article not found"}), 404
item, feed_title = row
conv_title = (item.title or "Article discussion")[:80]
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
from fabledassistant.services.rss import _fetch_full_article
source = feed_title or "News"
content_body = (await _fetch_full_article(item.url) if item.url else None) or (item.content or "").strip()
seeded_text = f"**{source}**\n\n**{item.title}**"
if content_body:
seeded_text += f"\n\n{content_body}"
if item.url:
seeded_text += f"\n\nSource: {item.url}"
async with _async_session() as session:
msg = Message(
conversation_id=conv.id,
role="assistant",
content=seeded_text,
msg_metadata={"rss_item_ids": [item_id]},
)
session.add(msg)
await session.commit()
return jsonify({"conversation_id": conv.id}), 201
+34 -12
View File
@@ -1,7 +1,7 @@
"""Calendar events REST API."""
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request
@@ -11,6 +11,14 @@ import fabledassistant.services.events as events_svc
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
def _parse_dt(value: str) -> datetime:
"""Parse ISO 8601 datetime string, ensuring UTC-awareness."""
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _get_current_user_id() -> int:
return g.user.id
@@ -23,8 +31,8 @@ async def list_events():
if not date_from_str or not date_to_str:
return jsonify({"error": "from and to query params are required"}), 400
try:
date_from = datetime.fromisoformat(date_from_str)
date_to = datetime.fromisoformat(date_to_str)
date_from = _parse_dt(date_from_str)
date_to = _parse_dt(date_to_str)
except ValueError:
return jsonify({"error": "Invalid datetime format"}), 400
events = await events_svc.list_events(
@@ -32,7 +40,7 @@ async def list_events():
date_from=date_from,
date_to=date_to,
)
return jsonify([e.to_dict() for e in events])
return jsonify(events)
@events_bp.post("")
@@ -42,8 +50,8 @@ async def create_event():
if not data.get("title") or not data.get("start_dt"):
return jsonify({"error": "title and start_dt are required"}), 400
try:
start_dt = datetime.fromisoformat(data["start_dt"])
end_dt = datetime.fromisoformat(data["end_dt"]) if data.get("end_dt") else None
start_dt = _parse_dt(data["start_dt"])
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
except ValueError:
return jsonify({"error": "Invalid datetime format"}), 400
event = await events_svc.create_event(
@@ -57,6 +65,7 @@ async def create_event():
color=data.get("color", ""),
recurrence=data.get("recurrence"),
project_id=data.get("project_id"),
reminder_minutes=data.get("reminder_minutes"),
)
return jsonify(event.to_dict()), 201
@@ -84,15 +93,19 @@ 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",):
for int_field in ("project_id", "reminder_minutes"):
if int_field in data:
fields[int_field] = data[int_field]
for dt_field in ("start_dt", "end_dt"):
if dt_field in data and data[dt_field]:
try:
fields[dt_field] = datetime.fromisoformat(data[dt_field])
except ValueError:
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
if dt_field in data:
if data[dt_field] is None:
# Explicit null clears the field (e.g. removing end_dt)
fields[dt_field] = None
elif data[dt_field]:
try:
fields[dt_field] = _parse_dt(data[dt_field])
except ValueError:
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
event = await events_svc.update_event(
user_id=_get_current_user_id(),
event_id=event_id,
@@ -117,3 +130,12 @@ async def delete_event(event_id: int):
event_id=event_id,
)
return "", 204
@events_bp.post("/sync")
@login_required
async def sync_caldav():
"""Trigger a CalDAV pull sync for the current user."""
from fabledassistant.services.caldav_sync import sync_user_events
result = await sync_user_events(user_id=_get_current_user_id())
return jsonify(result)
+3 -6
View File
@@ -1,14 +1,10 @@
"""Serve locally-cached images.
No authentication required — image IDs are opaque and unguessable (based
on the SHA-256 of the original URL). Images are served with a 1-day
Cache-Control header so the browser doesn't re-request on every page load.
"""
"""Serve locally-cached images."""
import logging
from quart import Blueprint, jsonify, send_file
from fabledassistant.auth import login_required
from fabledassistant.services.images import get_image_path, get_image_record
logger = logging.getLogger(__name__)
@@ -17,6 +13,7 @@ images_bp = Blueprint("images", __name__, url_prefix="/api/images")
@images_bp.route("/<int:image_id>", methods=["GET"])
@login_required
async def serve_image(image_id: int):
"""Serve a locally-cached image by its DB ID."""
record = await get_image_record(image_id)
+144
View File
@@ -0,0 +1,144 @@
"""Unified Knowledge endpoint — notes, people, places, lists in one queryable feed."""
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.routes.utils import parse_pagination
logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
_VALID_TYPES = {"note", "person", "place", "list"}
_VALID_SORTS = {"modified", "created", "alpha", "type"}
@knowledge_bp.route("", methods=["GET"])
@login_required
async def list_knowledge():
"""Return paginated knowledge objects with optional filtering.
Query params:
type — one of note|person|place|list (omit for all, excludes tasks)
tags — comma-separated tag filter (AND logic)
sort — modified|created|alpha|type (default: modified)
q — search query (semantic when provided, keyword fallback)
page — 1-based page number (default 1)
per_page — items per page (default 24, max 100)
"""
uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None
tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
sort = request.args.get("sort", "modified").strip().lower()
q = request.args.get("q", "").strip() or None
if note_type and note_type not in _VALID_TYPES:
return jsonify({"error": f"Invalid type. Must be one of: {', '.join(sorted(_VALID_TYPES))}"}), 400
if sort not in _VALID_SORTS:
sort = "modified"
limit, offset = parse_pagination(default_limit=24, max_limit=100)
page = max(1, int(request.args.get("page", 1)))
from fabledassistant.services.knowledge import query_knowledge
items, total = await query_knowledge(
user_id=uid,
note_type=note_type,
tags=tags,
sort=sort,
q=q,
limit=limit,
offset=offset,
)
return jsonify({
"items": items,
"total": total,
"page": page,
"per_page": limit,
"pages": max(1, (total + limit - 1) // limit),
})
@knowledge_bp.route("/ids", methods=["GET"])
@login_required
async def list_knowledge_ids():
"""Return note IDs only (cheap) for the two-tier pagination feed.
Same filter params as GET /api/knowledge.
Additional params: limit (default 100, max 200), offset (default 0).
Returns {ids, total, has_more}.
"""
uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None
tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
sort = request.args.get("sort", "modified").strip().lower()
q = request.args.get("q", "").strip() or None
if sort not in _VALID_SORTS:
sort = "modified"
try:
limit = min(int(request.args.get("limit", 100)), 200)
offset = max(0, int(request.args.get("offset", 0)))
except ValueError:
return jsonify({"error": "Invalid limit or offset"}), 400
if note_type and note_type not in _VALID_TYPES:
return jsonify({"error": "Invalid type"}), 400
from fabledassistant.services.knowledge import query_knowledge_ids
ids, total = await query_knowledge_ids(
user_id=uid, note_type=note_type, tags=tags,
sort=sort, q=q, limit=limit, offset=offset,
)
return jsonify({"ids": ids, "total": total, "has_more": (offset + len(ids)) < total})
@knowledge_bp.route("/batch", methods=["GET"])
@login_required
async def get_knowledge_batch():
"""Fetch full items for a comma-separated list of IDs (max 100).
Returns {items: [...]} in the order of the requested IDs.
"""
uid = get_current_user_id()
ids_raw = request.args.get("ids", "").strip()
if not ids_raw:
return jsonify({"items": []})
try:
ids = [int(x) for x in ids_raw.split(",") if x.strip()]
except ValueError:
return jsonify({"error": "Invalid IDs"}), 400
if len(ids) > 100:
return jsonify({"error": "Too many IDs (max 100)"}), 400
from fabledassistant.services.knowledge import get_knowledge_by_ids
items = await get_knowledge_by_ids(uid, ids)
return jsonify({"items": items})
@knowledge_bp.route("/tags", methods=["GET"])
@login_required
async def list_knowledge_tags():
"""Return all tags used across knowledge objects (excludes tasks)."""
uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None
from fabledassistant.services.knowledge import get_knowledge_tags
tags = await get_knowledge_tags(uid, note_type=note_type)
return jsonify({"tags": tags})
@knowledge_bp.route("/counts", methods=["GET"])
@login_required
async def get_knowledge_counts():
"""Return per-type counts — used by the sidebar to show item counts."""
uid = get_current_user_id()
tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None
from fabledassistant.services.knowledge import get_knowledge_counts as _counts
counts = await _counts(uid, tags=tags)
return jsonify(counts)
+42 -20
View File
@@ -5,16 +5,17 @@ from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.services.access import can_write_project
from fabledassistant.services.milestones import (
create_milestone,
delete_milestone,
get_milestone,
get_milestone_in_project,
get_milestone_progress,
list_milestones,
update_milestone,
)
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import get_project
from fabledassistant.services.projects import get_project_for_user
logger = logging.getLogger(__name__)
@@ -31,31 +32,38 @@ async def _milestone_dict(m) -> dict:
@login_required
async def list_milestones_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, _ = result
# List milestones using the project owner's uid so ownership filter matches
owner_uid = project.user_id or uid
status = request.args.get("status")
milestones = await list_milestones(uid, project_id, status=status)
result = [await _milestone_dict(m) for m in milestones]
return jsonify({"milestones": result})
milestones = await list_milestones(owner_uid, project_id, status=status)
return jsonify({"milestones": [await _milestone_dict(m) for m in milestones]})
@milestones_bp.route("/<int:project_id>/milestones", methods=["POST"])
@login_required
async def create_milestone_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
if not await can_write_project(uid, project_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
status = data.get("status", "active")
if status not in ("active", "done"):
return jsonify({"error": "status must be 'active' or 'done'"}), 400
milestone = await create_milestone(
uid,
project_id,
title=data["title"],
description=data.get("description"),
order_index=data.get("order_index", 0),
status=status,
)
return jsonify(await _milestone_dict(milestone)), 201
@@ -64,8 +72,10 @@ async def create_milestone_route(project_id: int):
@login_required
async def get_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None:
return not_found("Milestone")
return jsonify(await _milestone_dict(milestone))
@@ -74,13 +84,19 @@ async def get_milestone_route(project_id: int, milestone_id: int):
@login_required
async def update_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
if not await can_write_project(uid, project_id):
return jsonify({"error": "Permission denied"}), 403
milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None:
return not_found("Milestone")
data = await request.get_json()
allowed = {"title", "description", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed}
updated = await update_milestone(uid, milestone_id, **fields)
if "status" in fields and fields["status"] not in ("active", "done"):
return jsonify({"error": "status must be 'active' or 'done'"}), 400
updated = await update_milestone(milestone.user_id, milestone_id, **fields)
if updated is None:
return not_found("Milestone")
return jsonify(await _milestone_dict(updated))
@@ -90,10 +106,14 @@ async def update_milestone_route(project_id: int, milestone_id: int):
@login_required
async def delete_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
if not await can_write_project(uid, project_id):
return jsonify({"error": "Permission denied"}), 403
milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None:
return not_found("Milestone")
deleted = await delete_milestone(uid, milestone_id)
deleted = await delete_milestone(milestone.user_id, milestone_id)
if not deleted:
return not_found("Milestone")
return "", 204
@@ -103,13 +123,15 @@ async def delete_milestone_route(project_id: int, milestone_id: int):
@login_required
async def get_milestone_tasks_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None:
return not_found("Milestone")
status_filter = request.args.get("status")
limit, offset = parse_pagination(default_limit=100)
notes, total = await list_notes(
uid,
milestone.user_id,
is_task=True,
status=status_filter,
milestone_id=milestone_id,
+44 -20
View File
@@ -104,18 +104,26 @@ async def create_note_route():
if proj:
project_id = proj.id
note = await create_note(
uid,
title=data.get("title", ""),
body=body,
tags=tags,
parent_id=data.get("parent_id"),
project_id=project_id,
milestone_id=data.get("milestone_id"),
status=status,
priority=priority,
due_date=due_date,
)
note_type = data.get("note_type", "note")
entity_meta = data.get("metadata") or None
try:
note = await create_note(
uid,
title=data.get("title", ""),
body=body,
tags=tags,
parent_id=data.get("parent_id"),
project_id=project_id,
milestone_id=data.get("milestone_id"),
status=status,
priority=priority,
due_date=due_date,
note_type=note_type,
entity_meta=entity_meta,
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
@@ -207,9 +215,11 @@ async def update_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
if key in data:
fields[key] = data[key]
if "metadata" in data:
fields["entity_meta"] = data["metadata"] or None
if "due_date" in data:
if data["due_date"]:
@@ -222,7 +232,10 @@ async def update_note_route(note_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
try:
note = await update_note(uid, note_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
@@ -237,19 +250,30 @@ async def patch_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
if key in data:
fields[key] = data[key]
if "metadata" in data:
fields["entity_meta"] = data["metadata"] or None
if "due_date" in data:
result = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
if data["due_date"]:
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
try:
note = await update_note(uid, note_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
return jsonify(note.to_dict())
+61
View File
@@ -0,0 +1,61 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.services.user_profile import (
VALID_EXPERTISE,
VALID_STYLES,
VALID_TONES,
clear_learned_data,
consolidate_observations,
get_profile,
update_profile,
)
profile_bp = Blueprint("profile", __name__, url_prefix="/api/profile")
@profile_bp.route("", methods=["GET"])
@login_required
async def get_profile_route():
uid = get_current_user_id()
profile = await get_profile(uid)
return jsonify(profile.to_dict())
@profile_bp.route("", methods=["PUT"])
@login_required
async def update_profile_route():
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if "expertise_level" in data and data["expertise_level"] not in VALID_EXPERTISE:
return jsonify({"error": f"expertise_level must be one of {sorted(VALID_EXPERTISE)}"}), 400
if "response_style" in data and data["response_style"] not in VALID_STYLES:
return jsonify({"error": f"response_style must be one of {sorted(VALID_STYLES)}"}), 400
if "tone" in data and data["tone"] not in VALID_TONES:
return jsonify({"error": f"tone must be one of {sorted(VALID_TONES)}"}), 400
if "interests" in data and not isinstance(data["interests"], list):
return jsonify({"error": "interests must be an array"}), 400
if "work_schedule" in data and not isinstance(data["work_schedule"], dict):
return jsonify({"error": "work_schedule must be an object"}), 400
profile = await update_profile(uid, data)
return jsonify(profile.to_dict())
@profile_bp.route("/consolidate", methods=["POST"])
@login_required
async def trigger_consolidate():
uid = get_current_user_id()
summary = await consolidate_observations(uid)
return jsonify({"status": "ok", "learned_summary": summary})
@profile_bp.route("/observations", methods=["DELETE"])
@login_required
async def clear_observations():
uid = get_current_user_id()
await clear_learned_data(uid)
return jsonify({"status": "ok"})
+19
View File
@@ -1,4 +1,5 @@
"""Project management routes."""
import asyncio
import logging
from quart import Blueprint, jsonify, request
@@ -27,7 +28,19 @@ projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
async def list_projects_route():
uid = get_current_user_id()
status = request.args.get("status")
include_summary = request.args.get("include_summary", "").lower() in ("1", "true")
projects = await list_projects_for_user(uid, status=status)
if include_summary:
# Fetch all summaries in parallel — one backend pass instead of N+1 frontend calls
async def _attach(project_dict: dict) -> dict:
try:
owner_uid = project_dict.get("user_id") or uid # user_id now in to_dict()
summary = await get_project_summary(owner_uid, project_dict["id"])
project_dict["summary"] = summary
except Exception:
pass
return project_dict
projects = list(await asyncio.gather(*[_attach(p) for p in projects]))
return jsonify({"projects": projects})
@@ -38,12 +51,16 @@ async def create_project_route():
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
status = data.get("status", "active")
if status not in ("active", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await create_project(
uid,
title=data["title"],
description=data.get("description", ""),
goal=data.get("goal", ""),
color=data.get("color"),
status=status,
)
return jsonify(project.to_dict()), 201
@@ -72,6 +89,8 @@ async def update_project_route(project_id: int):
data = await request.get_json()
allowed = {"title", "description", "goal", "status", "color"}
fields = {k: v for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await update_project(uid, project_id, **fields)
if project is None:
return not_found("Project")
+12 -2
View File
@@ -3,9 +3,9 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.auth import login_required, get_current_user_id, admin_required
from fabledassistant.config import Config
from fabledassistant.services.push import delete_subscription, save_subscription, vapid_enabled
from fabledassistant.services.push import delete_subscription, regenerate_vapid_keys, save_subscription, vapid_enabled
logger = logging.getLogger(__name__)
@@ -42,3 +42,13 @@ async def unsubscribe():
return jsonify({"error": "endpoint is required"}), 400
await delete_subscription(uid, endpoint)
return "", 204
@push_bp.route("/reset-vapid", methods=["POST"])
@admin_required
async def reset_vapid():
"""Regenerate VAPID keys and clear all push subscriptions."""
ok = await regenerate_vapid_keys()
if ok:
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY}), 200
return jsonify({"error": "Key regeneration failed"}), 500
+42 -90
View File
@@ -1,77 +1,39 @@
"""Quick-capture endpoint for mobile/external clients.
POST /api/quick-capture — classifies natural-language text and creates the
appropriate item (note, task, calendar event, todo) in a single synchronous
request. No SSE, no conversation ID, no streaming.
POST /api/quick-capture — sends text through the main LLM tool-calling pipeline
and returns a single synchronous JSON response. No SSE, no conversation ID.
"""
import json
import logging
import re
from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.config import Config
from fabledassistant.services.intent import classify_capture_intent
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.generation_task import _should_think
from fabledassistant.services.llm import stream_chat_with_tools
from fabledassistant.services.tools import execute_tool, get_tools_for_user
logger = logging.getLogger(__name__)
quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture")
# Tools offered to the quick-capture classifier. Excludes destructive ops
# (delete_*) and read-only queries — worst-case fallback is a plain note.
# Tools offered to the quick-capture endpoint. Excludes destructive ops,
# read-only queries, and conversational-only tools.
_CAPTURE_TOOL_NAMES = {"create_note", "create_task", "create_event", "update_note", "research_topic"}
_NOTE_PROCESS_PROMPT = """\
You are a note-taking assistant. The user has sent a quick-capture snippet. \
Transform it into a well-formed note.
Respond with ONLY a JSON object — no other text, no code fences:
{{"title": "short descriptive title", "body": "note content in markdown"}}
Rules:
- title: 38 words, a genuine summary — do NOT copy the input verbatim
- body: process the input thoughtfully:
- Lists of items → formatted bullet list
- A stream-of-thought or observation → clean prose, lightly organised
- Raw notes or fragments → organised paragraphs with a brief intro line
- URLs → include the URL and a one-sentence description of what it points to
- Preserve ALL information from the original; do not invent new facts
- Use markdown formatting (##, -, **, etc.) where it aids readability
- Keep it concise — do not pad with filler"""
async def _process_note(text: str, model: str) -> tuple[str, str]:
"""Use the main model to transform raw capture text into a title + body.
Returns (title, body). Falls back to (truncated text, full text) on any failure.
"""
messages = [
{"role": "system", "content": _NOTE_PROCESS_PROMPT},
{"role": "user", "content": text},
]
try:
raw = await generate_completion(messages, model, max_tokens=1024, num_ctx=4096)
raw = raw.strip()
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw).strip()
parsed = json.loads(raw)
title = str(parsed.get("title", "")).strip() or text[:60]
body = str(parsed.get("body", "")).strip() or text
return title, body
except Exception:
logger.warning("Note processing LLM call failed, using raw text", exc_info=True)
fallback_title = text if len(text) <= 80 else text[:77] + "..."
return fallback_title, text
_SYSTEM_PROMPT = """\
Today is {today}. You are a quick-capture assistant. The user has sent a short \
snippet from their mobile device. Create the appropriate item — note, task, or \
calendar event — using the available tools. Always call a tool; never reply \
conversationally."""
@quick_capture_bp.route("", methods=["POST"])
@login_required
async def quick_capture_route():
"""Classify text and create the appropriate item, returning a single JSON response."""
"""Classify text via native tool-calling and create the appropriate item."""
uid = get_current_user_id()
data = await request.get_json(silent=True) or {}
text = data.get("text", "").strip()
@@ -81,26 +43,37 @@ async def quick_capture_route():
from fabledassistant.services.settings import get_setting
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
# Build tool list for this user, then restrict to capture-only operations.
all_tools = await get_tools_for_user(uid)
capture_tools = [
t for t in all_tools if t.get("function", {}).get("name") in _CAPTURE_TOOL_NAMES
]
intent = await classify_capture_intent(text, capture_tools, model)
messages = [
{"role": "system", "content": _SYSTEM_PROMPT.format(today=date.today().isoformat())},
{"role": "user", "content": text},
]
if intent.should_execute:
# research_topic bypasses execute_tool — run the pipeline directly
if intent.tool_name == "research_topic" and Config.searxng_enabled():
think = _should_think(text, think_requested=True)
tool_calls: list[dict] = []
try:
async for chunk in stream_chat_with_tools(messages, model, tools=capture_tools, think=think, num_ctx=4096):
if chunk.type == "tool_calls" and chunk.tool_calls:
tool_calls = chunk.tool_calls
except Exception:
logger.warning("Quick-capture LLM call failed for uid=%d", uid, exc_info=True)
if tool_calls:
tc = tool_calls[0]
tool_name = tc.get("function", {}).get("name", "")
arguments = tc.get("function", {}).get("arguments", {})
if tool_name == "research_topic" and Config.searxng_enabled():
from fabledassistant.services.research import run_research_pipeline
topic = intent.arguments.get("topic", text)
topic = arguments.get("topic", text)
try:
note = await run_research_pipeline(topic, uid, model)
logger.info(
"Quick-capture uid=%d: research note id=%d '%s'",
uid, note.id, note.title,
)
logger.info("Quick-capture uid=%d: research note id=%d '%s'", uid, note.id, note.title)
return jsonify({
"success": True,
"type": "note",
@@ -108,48 +81,27 @@ async def quick_capture_route():
"data": {"id": note.id, "title": note.title},
})
except Exception as exc:
logger.exception("Quick-capture research failed for topic: %s", topic)
logger.exception("Quick-capture research failed: %s", topic)
return jsonify({"error": f"Research failed: {exc}"}), 500
# For notes, run a second LLM pass to generate a proper title and
# well-formed body rather than using the raw capture text verbatim.
if intent.tool_name == "create_note":
title, body = await _process_note(text, model)
intent.arguments["title"] = title
intent.arguments["body"] = body
result = await execute_tool(uid, intent.tool_name, intent.arguments)
result = await execute_tool(uid, tool_name, arguments)
if result.get("success"):
item_type = result.get("type", "note")
title = (result.get("data") or {}).get("title", "")
logger.info(
"Quick-capture uid=%d: %s '%s' via intent '%s'",
uid, item_type, title, intent.tool_name,
)
logger.info("Quick-capture uid=%d: %s '%s'", uid, item_type, title)
return jsonify({
"success": True,
"type": item_type,
"message": f"{item_type.capitalize()}: {title}",
"data": result.get("data"),
})
logger.warning(
"Quick-capture uid=%d: tool '%s' returned failure: %s",
uid, intent.tool_name, result.get("error"),
)
# Fall through to plain-note fallback
logger.warning("Quick-capture uid=%d: tool '%s' failed: %s", uid, tool_name, result.get("error"))
# Fallback: classify_capture_intent returned no-tool (e.g. LLM parse failure).
# Still process the text through the note enrichment pass.
fallback_title, fallback_body = await _process_note(text, model)
result = await execute_tool(
uid, "create_note", {"title": fallback_title, "body": fallback_body}
)
# Fallback: create a plain note with the raw text
result = await execute_tool(uid, "create_note", {"title": text[:80], "body": text})
if result.get("success"):
title = (result.get("data") or {}).get("title", "")
logger.info(
"Quick-capture uid=%d: fallback note created '%s'", uid, title
)
logger.info("Quick-capture uid=%d: fallback note '%s'", uid, title)
return jsonify({
"success": True,
"type": "note",
+63 -4
View File
@@ -1,10 +1,43 @@
import asyncio
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
from fabledassistant.services.llm import get_installed_models
from fabledassistant.services.settings import delete_setting, get_all_settings, set_settings_batch
from fabledassistant.services.llm import get_installed_models, _is_private_url
from fabledassistant.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
logger = logging.getLogger(__name__)
async def _prime_kv_cache_bg(user_id: int, model: str) -> None:
"""Fire-and-forget: prime Ollama's KV cache with the user's system prompt."""
import httpx
from fabledassistant.services.llm import build_context, pick_num_ctx
try:
messages, _ = await build_context(
user_id=user_id,
history=[],
current_note_id=None,
user_message=" ",
)
num_ctx = pick_num_ctx(messages)
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"options": {"num_predict": 1, "num_ctx": num_ctx},
"keep_alive": "2h",
},
)
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
except Exception:
logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True)
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
@@ -32,10 +65,10 @@ async def update_settings_route():
if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400
# Empty string for default_model means "reset to system default".
# Empty string for model keys means "reset to system default".
# Delete the DB row so get_setting() falls back to Config defaults
# rather than returning "" and breaking model resolution everywhere.
_MODEL_KEYS = frozenset({"default_model"})
_MODEL_KEYS = frozenset({"default_model", "background_model"})
to_save = {}
for k, v in data.items():
str_v = str(v)
@@ -46,6 +79,22 @@ async def update_settings_route():
if to_save:
await set_settings_batch(uid, to_save)
# When timezone changes, live-patch the briefing scheduler immediately
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)
if "default_model" in to_save and to_save["default_model"]:
asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"]))
settings = await get_all_settings(uid)
return jsonify(settings)
@@ -77,6 +126,16 @@ async def update_caldav():
uid = get_current_user_id()
data = await request.get_json()
# Validate CalDAV URL before saving — block internal/private addresses
if "caldav_url" in data:
url = str(data.get("caldav_url") or "").strip()
if url:
parsed_scheme = url.split("://")[0].lower() if "://" in url else ""
if parsed_scheme not in ("http", "https"):
return jsonify({"error": "CalDAV URL must use http or https"}), 400
if _is_private_url(url):
return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400
settings_to_save = {}
for key in CALDAV_SETTING_KEYS:
if key in data:
+25 -5
View File
@@ -6,6 +6,7 @@ from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.services.access import can_write_note
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import (
create_note,
@@ -154,6 +155,13 @@ async def get_task_route(task_id: int):
@login_required
async def update_task_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
fields = {}
for key in ("title",):
@@ -198,12 +206,12 @@ async def update_task_route(task_id: int):
if recurrence_rule is not _UNSET:
fields["recurrence_rule"] = recurrence_rule
task = await update_note(uid, task_id, **fields)
task = await update_note(task_note.user_id, task_id, **fields)
if task is None:
return not_found("Task")
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
return jsonify(task.to_dict())
@@ -211,17 +219,23 @@ async def update_task_route(task_id: int):
@login_required
async def patch_task_status(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
status_val = data.get("status")
if not status_val:
return jsonify({"error": "status is required"}), 400
try:
TaskStatus(status_val)
except ValueError:
return jsonify({"error": f"Invalid status: {status_val}"}), 400
task = await update_note(uid, task_id, status=status_val)
task = await update_note(task_note.user_id, task_id, status=status_val)
if task is None:
return not_found("Task")
return jsonify(task.to_dict())
@@ -252,7 +266,13 @@ async def recurrence_preview_route(task_id: int):
@login_required
async def delete_task_route(task_id: int):
uid = get_current_user_id()
deleted = await delete_note(uid, task_id)
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
deleted = await delete_note(task_note.user_id, task_id)
if not deleted:
return not_found("Task")
return "", 204
+1 -1
View File
@@ -24,6 +24,6 @@ async def search_users():
).limit(10)
)).scalars().all()
return jsonify({"users": [
{"id": u.id, "username": u.username, "email": u.email}
{"id": u.id, "username": u.username}
for u in users
]})
+187
View File
@@ -0,0 +1,187 @@
"""Voice (Speech-to-Speech) routes at /api/voice."""
import logging
import time
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required
logger = logging.getLogger(__name__)
voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice")
@voice_bp.route("/status", methods=["GET"])
@login_required
async def voice_status():
"""Return availability of STT and TTS services."""
from fabledassistant.services.voice_config import get_voice_config
from fabledassistant.services.stt import stt_available
from fabledassistant.services.tts import tts_available
config = await get_voice_config()
enabled = config.get("voice_enabled", "false").lower() in ("1", "true", "yes")
if not enabled:
return jsonify({"enabled": False, "stt": False, "tts": False})
return jsonify({
"enabled": True,
"stt": stt_available(),
"tts": tts_available(),
"stt_model": config.get("voice_stt_model", "base.en"),
"tts_backend": "kokoro",
})
@voice_bp.route("/voices", methods=["GET"])
@login_required
async def list_voices():
"""Return available Kokoro voice IDs and labels."""
from fabledassistant.services.voice_config import is_voice_enabled
if not await is_voice_enabled():
return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.tts import list_voices, tts_available
if not tts_available():
return jsonify({"error": "TTS not available"}), 503
return jsonify({"voices": list_voices()})
@voice_bp.route("/transcribe", methods=["POST"])
@login_required
async def transcribe_audio():
"""Accept a multipart audio file and return the transcript.
Request: multipart/form-data with field 'audio' (WebM/Opus blob)
Response: {"transcript": "...", "duration_ms": 123}
"""
from fabledassistant.services.voice_config import is_voice_enabled
if not await is_voice_enabled():
return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.stt import stt_available, transcribe
if not stt_available():
return jsonify({"error": "STT not available — model may still be loading"}), 503
files = await request.files
audio_file = files.get("audio")
if audio_file is None:
return jsonify({"error": "No audio file provided"}), 400
audio_bytes = audio_file.read()
if not audio_bytes:
return jsonify({"error": "Empty audio file"}), 400
if len(audio_bytes) > 25 * 1024 * 1024: # 25 MB hard cap
return jsonify({"error": "Audio file too large (max 25 MB)"}), 413
mime_type = audio_file.content_type or "audio/webm"
t0 = time.monotonic()
try:
transcript = await transcribe(audio_bytes, mime_type)
except Exception:
logger.exception("STT transcription failed")
return jsonify({"error": "Transcription failed"}), 500
duration_ms = round((time.monotonic() - t0) * 1000)
return jsonify({"transcript": transcript, "duration_ms": duration_ms})
@voice_bp.route("/synthesise", methods=["POST"])
@login_required
async def synthesise_speech():
"""Convert text to speech and return WAV bytes.
Request body: {"text": "...", "voice": "af_heart", "speed": 1.0}
Response: audio/wav bytes
"""
from fabledassistant.services.voice_config import is_voice_enabled
if not await is_voice_enabled():
return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.tts import synthesise, tts_available
if not tts_available():
return jsonify({"error": "TTS not available — model may still be loading"}), 503
data = await request.get_json()
if not data:
return jsonify({"error": "JSON body required"}), 400
text = str(data.get("text", "")).strip()
if not text:
return jsonify({"error": "text is required"}), 400
char_count = len(text)
if char_count > 8000:
logger.warning(
"TTS request rejected: text too long (%d chars, limit 8000). Preview: %r",
char_count, text[:120],
)
return jsonify({"error": "text too long (max 8000 characters)"}), 400
voice = str(data.get("voice", "af_heart"))
try:
speed = float(data.get("speed", 1.0))
except (TypeError, ValueError):
speed = 1.0
voice_blend = data.get("voice_blend")
if not isinstance(voice_blend, list) or len(voice_blend) < 2:
voice_blend = None
# When no explicit voice/blend/speed provided, load all voice settings from the user's profile
if "voice" not in data and "voice_blend" not in data and "speed" not in data:
from fabledassistant.services.settings import get_setting
from fabledassistant.auth import get_current_user_id
import json as _json
try:
uid = get_current_user_id()
saved_voice = await get_setting(uid, "voice_tts_voice", "")
if saved_voice:
voice = saved_voice
saved_speed = await get_setting(uid, "voice_tts_speed", "")
if saved_speed:
try:
speed = float(saved_speed)
except ValueError:
pass
saved_blend = await get_setting(uid, "voice_tts_blend", "")
if saved_blend:
parsed = _json.loads(saved_blend)
if isinstance(parsed, list) and len(parsed) >= 2:
voice_blend = parsed
except Exception:
pass
blend_desc = f"blend({len(voice_blend)} voices)" if voice_blend else voice
logger.info("TTS synthesis start: %d chars, voice=%s, speed=%.2f", char_count, blend_desc, speed)
t0 = time.monotonic()
try:
wav_bytes = await synthesise(text, voice=voice, speed=speed, voice_blend=voice_blend)
except Exception:
logger.exception(
"TTS synthesis failed: %d chars, voice=%s. Preview: %r",
char_count, blend_desc, text[:120],
)
return jsonify({"error": "Synthesis failed"}), 500
duration_ms = round((time.monotonic() - t0) * 1000)
if not wav_bytes:
logger.warning(
"TTS synthesis returned empty audio: %d chars, voice=%s, %dms. Preview: %r",
char_count, blend_desc, duration_ms, text[:120],
)
else:
logger.info(
"TTS synthesis complete: %d chars → %d bytes in %dms (voice=%s)",
char_count, len(wav_bytes), duration_ms, blend_desc,
)
from quart import Response
return Response(wav_bytes, mimetype="audio/wav")
+84 -112
View File
@@ -7,7 +7,7 @@ Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (
import asyncio
import hashlib
import logging
from datetime import date, datetime, timezone
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import httpx
@@ -22,14 +22,6 @@ logger = logging.getLogger(__name__)
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
def slot_greeting(slot: str) -> str:
return {
"compilation": "Good morning",
"morning": "Good morning — you're at the office",
"midday": "Midday check-in",
"afternoon": "End of day wrap-up",
}.get(slot, "Update")
def format_task(task: dict) -> str:
parts = [task.get("title", "Untitled")]
@@ -180,14 +172,18 @@ async def _gather_internal(user_id: int) -> dict:
user_id=user_id, date_from=day_start, date_to=day_end
)
for e in internal_events:
if e.all_day:
if e.get("all_day"):
time_str = "all day"
elif e.start_dt:
local_dt = e.start_dt.astimezone(user_tz) if e.start_dt.tzinfo else e.start_dt.replace(tzinfo=timezone.utc).astimezone(user_tz)
elif e.get("start_dt"):
from datetime import datetime as _dt
start = e["start_dt"]
if isinstance(start, str):
start = _dt.fromisoformat(start)
local_dt = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
time_str = local_dt.strftime("%-I:%M %p")
else:
time_str = "unknown time"
calendar_events.append(f"{e.title} at {time_str}")
calendar_events.append(f"{e.get('title', 'Event')} at {time_str}")
except Exception:
logger.warning("Failed to gather internal calendar events for briefing", exc_info=True)
# Also pull CalDAV events (deduped)
@@ -241,7 +237,7 @@ async def _gather_external(user_id: int) -> dict:
# ── LLM synthesis ─────────────────────────────────────────────────────────────
async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> str:
async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str, num_ctx: int = 4096) -> str:
"""Single non-streaming LLM call. Returns the assistant's response text."""
payload = {
"model": model,
@@ -250,7 +246,7 @@ async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> s
{"role": "user", "content": user_prompt},
],
"stream": False,
"options": {"num_ctx": 4096, "temperature": 0.4},
"options": {"num_ctx": num_ctx, "temperature": 0.4},
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
@@ -263,57 +259,73 @@ async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> s
return ""
def _internal_system_prompt(profile_body: str) -> str:
def _unified_system_prompt(profile_body: str) -> str:
return (
"You are a personal briefing assistant. Your job is to give the user a clear, "
"concise summary of their internal workload: tasks, calendar, and projects. "
"Be direct and prioritised — lead with what's urgent. Use plain text with light "
"markdown. Do not include weather or news.\n\n"
"You are a personal assistant delivering a daily briefing. "
"Speak naturally and conversationally — as if talking to the user, not writing a report. "
"Use no markdown: no headers, no bullet points, no bold, no lists. Write in flowing prose. "
"Weave together what matters today: mention the weather in a sentence, note any calendar "
"events or tasks due today, and briefly reference one or two noteworthy news stories. "
"Only mention projects if a task from one is specifically due today. "
"Be warm, concise, and human — aim for 3 to 5 sentences. "
"Future context like emails and messages will be added over time — keep the tone open and helpful.\n\n"
+ (f"User profile:\n{profile_body}\n" if profile_body else "")
)
def _external_system_prompt() -> str:
return (
"You are a briefing assistant for external information. Your job is to present "
"selected news items and summarise any remaining RSS content. "
"IMPORTANT: Weather is handled separately — do NOT include any weather section.\n\n"
"Format each news item EXACTLY as:\n"
"**[Headline text](source_url)**\n"
"*Outlet Name · Day Month*\n"
"One or two sentence summary.\n\n"
"Present news items in the EXACT ORDER they are provided. Do not reorder them. "
"After the news cards, add a brief paragraph for any remaining context."
)
def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str:
lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""]
# Weather (brief — card handles detail)
weather = external_data.get("weather") or []
if weather:
loc = weather[0]
days = loc.get("days") or []
if days:
d = days[0]
t_min = _format_temp(d["temp_min"], temp_unit)
t_max = _format_temp(d["temp_max"], temp_unit)
unit_sym = f"°{temp_unit}"
lines.append(
f"WEATHER: {loc['location_label']}{d['description']}, "
f"{t_min}{t_max}{unit_sym}"
)
lines.append("")
def _internal_user_prompt(data: dict, slot: str) -> str:
lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""]
if data.get("unchanged_task_count", 0) > 0:
lines.append(
f"({data['unchanged_task_count']} tasks are unchanged since the last briefing "
"— acknowledge briefly, do not list them.)"
)
# Today's calendar events
if internal_data.get("calendar_events"):
lines.append("TODAY'S EVENTS:")
lines.extend(f" - {e}" for e in internal_data["calendar_events"])
lines.append("")
changed = data.get("changed_tasks") or data.get("overdue_tasks", [])
if changed:
lines.append(f"CHANGED/NEW TASKS ({len(changed)}):")
lines.extend(f" - {t}" for t in changed)
# Tasks due today
if internal_data.get("due_today"):
lines.append("DUE TODAY:")
lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("")
if data.get("due_today"):
lines.append(f"DUE TODAY ({len(data['due_today'])}):")
lines.extend(f" - {t}" for t in data["due_today"])
# Overdue tasks (brief mention only)
if internal_data.get("overdue_tasks"):
overdue = internal_data["overdue_tasks"]
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}):")
lines.extend(f" - {t}" for t in overdue[:3])
if len(overdue) > 3:
lines.append(f" (and {len(overdue) - 3} more)")
lines.append("")
if data.get("high_priority"):
lines.append("HIGH PRIORITY (in progress):")
lines.extend(f" - {t}" for t in data["high_priority"])
# News highlights (top 3 with excerpts — right panel shows full list)
rss = external_data.get("rss_items") or []
if rss:
lines.append("NEWS HIGHLIGHTS (weave 1-2 into your briefing naturally; the full list is shown separately):")
for item in rss[:3]:
source = item.get("feed_title") or item.get("source") or "News"
title = item.get("title", "")
excerpt = (item.get("content") or item.get("snippet") or "")[:500].strip()
lines.append(f" [{source}] {title}")
if excerpt:
lines.append(f" {excerpt}")
lines.append("")
if data["calendar_events"]:
lines.append("CALENDAR TODAY:")
lines.extend(f" - {e}" for e in data["calendar_events"])
lines.append("")
if data["active_projects"]:
lines.append(f"ACTIVE PROJECTS: {', '.join(data['active_projects'])}")
return "\n".join(lines)
@@ -324,31 +336,6 @@ def _format_temp(value: float, unit: str) -> str:
return f"{value:.0f}"
def _external_user_prompt(data: dict, slot: str, temp_unit: str = "C") -> str:
unit_sym = f"°{temp_unit}"
lines = [f"Briefing slot: {slot}", ""]
if data["weather"]:
lines.append("WEATHER:")
for loc in data["weather"]:
lines.append(f" {loc['location_label']}:")
for day in loc["days"][:3]:
t_min = _format_temp(day["temp_min"], temp_unit)
t_max = _format_temp(day["temp_max"], temp_unit)
lines.append(
f" {day['date']}: {day['description']}, "
f"{t_min}{t_max}{unit_sym}, {day['precip_mm']}mm rain"
)
if loc["changes_since_last_fetch"]:
lines.append(" FORECAST CHANGES:")
lines.extend(f" - {c}" for c in loc["changes_since_last_fetch"])
lines.append("")
if data["rss_items"]:
lines.append(f"RSS DIGEST ({len(data['rss_items'])} items):")
for item in data["rss_items"][:15]:
lines.append(f" [{item.get('feed_title', 'Feed')}] {item['title']}")
if item.get("content"):
lines.append(f" {item['content'][:200]}")
return "\n".join(lines)
# ── Main entry point ───────────────────────────────────────────────────────────
@@ -378,7 +365,7 @@ async def run_compilation(
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
from fabledassistant.services.briefing_profile import get_profile_body
from fabledassistant.services.user_profile import build_profile_context
from fabledassistant.services.briefing_preferences import (
load_topic_preferences,
load_topic_reaction_scores,
@@ -386,8 +373,8 @@ async def run_compilation(
)
from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows
profile_body, temp_unit = await asyncio.gather(
get_profile_body(user_id),
profile_context, temp_unit = await asyncio.gather(
build_profile_context(user_id),
_get_temp_unit(user_id),
)
@@ -434,7 +421,6 @@ async def run_compilation(
# ── LLM Synthesis ──────────────────────────────────────────────────────────
# Build filtered internal data with only changed tasks
today = internal_data["date"]
internal_data_filtered = dict(internal_data)
internal_data_filtered["unchanged_task_count"] = unchanged_count
internal_data_filtered["changed_tasks"] = [format_task(t) for t in changed_tasks]
@@ -445,17 +431,11 @@ async def run_compilation(
"weather": [],
}
internal_text, external_text = await asyncio.gather(
_llm_synthesise(
_internal_system_prompt(profile_body),
_internal_user_prompt(internal_data_filtered, slot),
model,
),
_llm_synthesise(
_external_system_prompt(),
_external_user_prompt(external_data_filtered, slot, temp_unit),
model,
),
briefing_text = await _llm_synthesise(
_unified_system_prompt(profile_context),
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
model,
num_ctx=8192,
)
# ── Post-processing ─────────────────────────────────────────────────────────
@@ -463,18 +443,11 @@ async def run_compilation(
metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "weather": weather_card}
if not internal_text and not external_text:
if not briefing_text:
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
return "", metadata
greeting = slot_greeting(slot)
parts = [f"**{greeting}{today}**", ""]
if internal_text:
parts += ["## Your Day", "", internal_text, ""]
if external_text:
parts += ["## The World", "", external_text]
return "\n".join(parts).strip(), metadata
return briefing_text, metadata
async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str:
@@ -492,13 +465,12 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None)
)
system = (
f"You are a briefing assistant providing a {slot} update. Be brief — "
"the user has already seen the morning briefing. Focus on what's changed or new."
f"You are a personal assistant giving a brief {slot} check-in. "
"The user already had their morning briefing — focus only on what's changed or newly relevant. "
"Speak naturally in 2-3 sentences, no markdown formatting, no headers or bullet points."
)
user_prompt = (
f"Slot: {slot}\n\n"
+ _internal_user_prompt(internal_data, slot)
+ "\n\n"
+ _external_user_prompt(external_data, slot, temp_unit)
return await _llm_synthesise(
system,
_unified_user_prompt(internal_data, external_data, slot, temp_unit),
model,
)
return await _llm_synthesise(system, user_prompt, model)
@@ -51,8 +51,8 @@ def _resolve_timezone(tz_str: str) -> str:
return "UTC"
async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
"""Return [(user_id, iana_timezone)] for all users with briefing enabled."""
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(
@@ -71,7 +71,7 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
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))
enabled.append((user_id, tz, config))
except Exception:
pass
return enabled
@@ -81,16 +81,24 @@ 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) -> None:
"""Add (or replace) all 4 slot jobs for a user in their timezone."""
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=_job_id(user_id, slot_name),
id=jid,
replace_existing=True,
misfire_grace_time=3600,
)
@@ -119,7 +127,7 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N
if config.get("enabled"):
tz_str = tz_override or config.get("timezone", "UTC")
tz = _resolve_timezone(tz_str)
_add_user_jobs(user_id, tz)
_add_user_jobs(user_id, tz, config)
else:
_remove_user_jobs(user_id)
@@ -135,6 +143,24 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
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":
@@ -208,7 +234,7 @@ 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.briefing_profile import append_observations
from fabledassistant.services.user_profile import append_observations
from fabledassistant.services.briefing_pipeline import _llm_synthesise
from fabledassistant.models.conversation import Conversation, Message
@@ -255,7 +281,7 @@ async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
(one catch-up per slot per user, evaluated in the user's local timezone).
"""
users = await _get_briefing_enabled_users()
for user_id, tz in users:
for user_id, tz, _config in users:
user_tz = ZoneInfo(tz)
now_local = datetime.now(user_tz)
today_local = now_local.date()
@@ -323,8 +349,8 @@ async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
logger.exception("Failed to load briefing users at startup")
users = []
for user_id, tz in users:
_add_user_jobs(user_id, tz)
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
@@ -343,6 +369,21 @@ async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
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)",
+192
View File
@@ -0,0 +1,192 @@
"""CalDAV pull sync — imports remote events into the internal event store.
Runs as a scheduled job (hourly) and is also callable via the API.
Only syncs events in a rolling 30-day-past / 180-day-future window.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
logger = logging.getLogger(__name__)
_SYNC_PAST_DAYS = 30
_SYNC_FUTURE_DAYS = 180
def _parse_dt(val: Any) -> datetime | None:
"""Convert a date or datetime from an iCal component to a UTC-aware datetime."""
if val is None:
return None
import datetime as _dt_mod
if isinstance(val, _dt_mod.datetime):
if val.tzinfo is None:
return val.replace(tzinfo=timezone.utc)
return val.astimezone(timezone.utc)
if isinstance(val, _dt_mod.date):
# All-day date: treat as midnight UTC
return datetime(val.year, val.month, val.day, tzinfo=timezone.utc)
return None
def _sync_one_user(config: dict[str, str], user_id: int) -> list[dict]:
"""Synchronous CalDAV fetch — runs in a thread executor."""
import caldav # noqa: PLC0415
now = datetime.now(timezone.utc)
range_start = now - timedelta(days=_SYNC_PAST_DAYS)
range_end = now + timedelta(days=_SYNC_FUTURE_DAYS)
client = caldav.DAVClient(
url=config["caldav_url"],
username=config.get("caldav_username") or None,
password=config.get("caldav_password") or None,
)
principal = client.principal()
calendars = principal.calendars()
if not calendars:
return []
cal_name = config.get("caldav_calendar_name", "")
if cal_name:
calendars = [c for c in calendars if c.name == cal_name] or calendars
events: list[dict] = []
for calendar in calendars:
try:
results = calendar.date_search(start=range_start, end=range_end, expand=False)
except Exception:
logger.warning("CalDAV date_search failed for calendar %s", getattr(calendar, "name", "?"), exc_info=True)
continue
for vevent_obj in results:
try:
ical = vevent_obj.icalendar_instance
for component in ical.walk():
if component.name != "VEVENT":
continue
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
uid = str(component.get("UID", ""))
if not uid:
continue
start_dt = _parse_dt(dtstart.dt if dtstart else None)
end_dt = _parse_dt(dtend.dt if dtend else None)
if start_dt is None:
continue
import datetime as _dt_mod
all_day = dtstart and isinstance(dtstart.dt, _dt_mod.date) and not isinstance(dtstart.dt, _dt_mod.datetime)
rrule = component.get("RRULE")
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
events.append({
"caldav_uid": uid,
"title": str(component.get("SUMMARY", "")),
"start_dt": start_dt,
"end_dt": end_dt,
"all_day": bool(all_day),
"description": str(component.get("DESCRIPTION", "")),
"location": str(component.get("LOCATION", "")),
"recurrence": recurrence,
})
except Exception:
logger.debug("Failed to parse CalDAV event", exc_info=True)
return events
async def sync_user_events(user_id: int) -> dict:
"""Pull CalDAV events for one user and upsert into the DB.
Returns a summary dict: {created, updated, unchanged}.
"""
from fabledassistant.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415
if not await is_caldav_configured(user_id):
return {"skipped": True, "reason": "CalDAV not configured"}
config = await get_caldav_config(user_id)
loop = asyncio.get_running_loop()
try:
remote_events: list[dict] = await loop.run_in_executor(
None, _sync_one_user, config, user_id
)
except Exception:
logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True)
return {"error": "CalDAV fetch failed"}
created = updated = unchanged = 0
async with async_session() as session:
for ev in remote_events:
caldav_uid = ev["caldav_uid"]
result = await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.caldav_uid == caldav_uid,
)
)
existing = result.scalar_one_or_none()
if existing is None:
# Create new event
new_ev = Event(
user_id=user_id,
uid=str(uuid.uuid4()),
caldav_uid=caldav_uid,
title=ev["title"],
start_dt=ev["start_dt"],
end_dt=ev["end_dt"],
all_day=ev["all_day"],
description=ev["description"],
location=ev["location"],
recurrence=ev["recurrence"],
)
session.add(new_ev)
created += 1
else:
# Update if anything changed
changed = False
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"):
if getattr(existing, field) != ev[field]:
setattr(existing, field, ev[field])
changed = True
if changed:
updated += 1
else:
unchanged += 1
await session.commit()
logger.info(
"CalDAV sync user %d: %d created, %d updated, %d unchanged",
user_id, created, updated, unchanged,
)
return {"created": created, "updated": updated, "unchanged": unchanged}
async def sync_all_users() -> None:
"""Pull CalDAV events for all users with CalDAV configured."""
from sqlalchemy import select as sa_select # noqa: PLC0415
from fabledassistant.models.user import User # noqa: PLC0415
async with async_session() as session:
result = await session.execute(sa_select(User.id))
user_ids = [row[0] for row in result.all()]
for user_id in user_ids:
try:
await sync_user_events(user_id)
except Exception:
logger.warning("CalDAV sync failed for user %d", user_id, exc_info=True)
+9 -5
View File
@@ -81,6 +81,7 @@ async def list_conversations(
"model": conv.model,
"conversation_type": conv.conversation_type,
"briefing_date": conv.briefing_date.isoformat() if conv.briefing_date else None,
"rag_project_id": conv.rag_project_id,
"message_count": row[1],
"created_at": conv.created_at.isoformat(),
"updated_at": conv.updated_at.isoformat(),
@@ -131,7 +132,9 @@ async def cleanup_old_conversations(user_id: int, days: int) -> int:
.where(
Conversation.user_id == user_id,
Conversation.updated_at < cutoff,
Conversation.conversation_type != "mcp", # preserve MCP audit trail
Conversation.conversation_type != "mcp", # preserve MCP audit trail
Conversation.conversation_type != "voice", # voice convs managed separately
Conversation.conversation_type != "briefing", # briefing history managed by briefing system
)
.returning(Conversation.id)
)
@@ -183,6 +186,7 @@ async def add_message(
content: str,
context_note_id: int | None = None,
status: str | None = None,
tool_calls: list | None = None,
) -> Message:
async with async_session() as session:
kwargs: dict = dict(
@@ -193,6 +197,8 @@ async def add_message(
)
if status is not None:
kwargs["status"] = status
if tool_calls is not None:
kwargs["tool_calls"] = tool_calls
msg = Message(**kwargs)
session.add(msg)
# Touch conversation updated_at
@@ -222,9 +228,6 @@ async def save_response_as_note(user_id: int, message_id: int) -> dict:
# Generate title via LLM using the assistant message content
title = ""
if conv:
model = conv.model or await get_setting(
user_id, "default_model", Config.OLLAMA_MODEL
)
try:
prompt_messages = [
{
@@ -237,7 +240,8 @@ async def save_response_as_note(user_id: int, message_id: int) -> dict:
},
{"role": "user", "content": msg.content[:2000]},
]
title = await generate_completion(prompt_messages, model)
bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
title = await generate_completion(prompt_messages, bg_model)
title = title.strip().strip('"\'').strip()[:100]
except Exception:
logger.warning("Failed to generate note title, using fallback", exc_info=True)

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