Compare commits

...

141 Commits

Author SHA1 Message Date
bvandeusen aca34e2dfb Merge pull request 'Release v26.04.16.3 — Silero VAD + Briefing layout' (#38) from dev into main 2026-04-16 23:37:04 +00:00
bvandeusen c07a0f0f7e fix(chat): remove input bar border across all chat views
Remove border-top from .input-wrapper in ChatPanel directly instead
of overriding per-view. Applies to chat, workspace, and briefing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:25:48 -04:00
bvandeusen 776e5edb68 fix(briefing): responsive sidebar, remove dividers, scale forecast icons
Switch sidebar from fixed px to proportional 35% so it resizes with
the viewport. Remove border between chat and input bar and between
weather and news. Use container query units for weather/forecast icons
so they scale with column width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:10:49 -04:00
bvandeusen 8b0dd732f7 feat(briefing): tabbed weather locations, sticky weather, wider sidebar
Add location tabs when multiple weather sources configured. Weather
section stays pinned at top of sidebar while news scrolls independently.
Bump sidebar width from 420px to 620px max for better readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:30:16 -04:00
bvandeusen f12d29563a fix(briefing): 2-column layout with table-based weather forecast
Collapse briefing from 3-column to 2-column grid (chat + sidebar).
Weather moves to top of news column, forecast uses an HTML table so
rows align cleanly. Drop verbose condition text from forecast days
(emoji already conveys it). Switch CI node_modules cache to npm
download cache to avoid tar size error from onnxruntime-web.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:10:46 -04:00
bvandeusen a4995606e5 chore(voice): remove amplitude-based silence detector (replaced by VAD)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:47:45 -04:00
bvandeusen 0a8a755909 feat(voice): replace silence detector with Silero VAD in ChatInputBar
Swaps useSilenceDetector for useVad. Adds no-speech guard: when the
user manually stops recording without VAD ever detecting speech, show
a toast and skip the Whisper round-trip instead of sending pure noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:45:21 -04:00
bvandeusen 719de12a6c feat(voice): add useVad composable wrapping Silero VAD
Provides speech-start/speech-end detection via @ricky0123/vad-web.
Retains amplitude-based visual feedback for mic pulse animation but no
longer uses it for stop decisions. Exposes stopAndCheck() which fires
onNoSpeech when the user manually stops without VAD ever detecting
speech — avoids wasted Whisper round-trips on noise-only recordings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:43:17 -04:00
bvandeusen 72018aa389 feat(voice): add ONNX WASM idle preloader for Silero VAD
Configures vite-plugin-static-copy to serve @ricky0123/vad-web's ONNX model,
audio worklet, and onnxruntime-web WASM files from the site root.
useOnnxPreloader warms the model cache during page idle so the first mic
click is instant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:41:17 -04:00
bvandeusen 1e73ea04f1 chore(frontend): add @ricky0123/vad-web dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 13:37:37 -04:00
bvandeusen 72708475a3 Release v26.04.16.1 — Fix tool actions mismatch in system prompt 2026-04-16 12:39:02 +00:00
bvandeusen e07d8436b7 fix(llm): sync available actions list with actual registered tools
The system prompt listed phantom tools (create_task, delete_task, get_note)
that don't exist, causing the model to spiral when users asked to create
tasks under a project. Replaced the stale hardcoded string with a
dynamically-built actions list matching all registered tools, and added
conditional searxng/caldav extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 08:16:36 -04:00
bvandeusen 1711ee6a1f Release v26.04.15.1 — Chat UI overhaul + silent gen fix 2026-04-16 05:52:47 +00:00
bvandeusen 369cf0a7c9 fix(chat): default context sidebar to hidden
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 23:29:14 -04:00
bvandeusen 3f2813d16f feat(chat): Phase D empty-state + overlay context sidebar
Empty-state rethink: replace "Start a conversation." with a richer
landing showing recent conversations (top 5 as resume links) and a
voice-mode entry button. Gated to full variant only; widget/briefing
keep the simple message.

Context sidebar: switch from grid column (4-col layout) to absolute
overlay on the right gutter (3-col layout). The reading column is now
always symmetrically centered regardless of whether the sidebar is
visible — eliminates the leftward shift that occurred when context
notes appeared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 22:35:39 -04:00
bvandeusen fddac2aa2f fix(chat): always think on qwen3, drop content-based classifier
Content-based gating (_should_think) was introduced in 87fcaa6 to cut
TTFT on simple prompts, but it has no way to tell that short prompts
like "create a task titled X" are going to trigger a tool call — and
qwen3:14b's tool-call template is unreliable at think=False, producing
intermittent silent generations where output tokens burn but nothing
parses into content or tool_calls.

Reverting to always-on thinking restores the pre-87fcaa6 reliability
of tool emission at the cost of TTFT latency on short conversational
prompts. This also lets us delete the silent-round retry loop (which
can no longer fire) along with its bookkeeping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:09:16 -04:00
bvandeusen 1261e93ede chore(generation): track CTX_DIAG per attempt not per round
Retry attempts were previously conflated with the initial call,
making prompt_tokens and headroom look cumulative and useless for
diagnosing the silent-round behavior. Move start-of-attempt captures
inside the retry loop and emit attempt_start / attempt_end lines so
each attempt's numbers stand alone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:39:49 -04:00
bvandeusen b6165e56e5 chore(generation): add CTX_DIAG logs for silent-round investigation
Log num_ctx, message count, prompt/output tokens, headroom, and a
silent flag per round so we can correlate silent generations against
context pressure on the dev instance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:09:08 -04:00
bvandeusen 5e281c534a fix(chat): add bottom padding to messages container to clear input bar
Streaming bubble (Generating response…) was flush against the input
wrapper top border. Give the scroll column 0.75rem of breathing room
so the assistant bubble sits visibly separate from the input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 19:29:14 -04:00
bvandeusen 9082281225 feat(chat): Phase C — collapsible context sidebar with header toggle
Context sidebar sections (Auto-included / Suggested / In Context) are
now collapsible with chevrons, and collapsed state persists per
conversation via localStorage. A new Context (N) button in the chat
header toggles the whole sidebar — open by default on wide screens
(>=1200px), closed by default below. Visual differentiation: auto-
included notes get a muted background with an AUTO pill, in-context
notes get an active-chip style with a primary-colored left border,
and each section header shows its item count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 19:12:11 -04:00
bvandeusen 058d6089b1 fix(chat): retry silent rounds with think=True before falling back
Qwen3's tool-call tokens sometimes fail to parse into either content
or tool_calls, burning output tokens and producing empty bubbles.
Detect the signature within a round (empty content, no tool calls,
eval_count > 0) and re-run the same round once with reasoning mode
enabled, which emits more reliable output. The post-loop fallback
remains as the final catch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 18:22:28 -04:00
bvandeusen ba0cb07c91 fix(chat): surface silent generations instead of empty bubbles
Qwen3:14b sometimes burns output tokens on tool-calling attempts whose
emission doesn't parse into any field we read — eval_count > 0 but no
thinking/content/tool_calls ever stream to the caller. Generation
completes "successfully," the user sees an empty assistant bubble, and
no error is logged. Seen in conv 220 today.

Two safety rails:

- stream_chat_with_tools now tracks whether it yielded anything; when
  Ollama's done frame reports eval_count > 0 with zero yields, log a
  warning including the last ~5 raw frames so the next occurrence leaves
  breadcrumbs for diagnosis.

- run_generation checks the same post-condition after the tool loop
  exits and, if content is empty with no tool calls but output_tokens
  > 0, substitutes a visible fallback message and streams it as a chunk
  so the user gets something readable instead of a blank bubble.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 17:35:30 -04:00
bvandeusen 4228e9a384 feat(chat): move scope chip to header, consolidate voice controls into input bar
Phase B of chat view refinement. Bumps chat reading column to 1200px,
lifts the RAG scope chip out of the footer and into the ChatView header
(next to the title), and collapses the listen toggle / volume slider /
stop-playback buttons into a single speaker popover on the input bar.
Listen mode + TTS are now available in briefing and workspace chats too,
since the controls live on the shared ChatInputBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:44:16 -04:00
bvandeusen 035ec0c0dc feat(chat): Phase A chrome cleanup — kebabs, search, full-width new chat
Header:
- Remove Research button + modal (skill fires reliably from normal chat)
- Move Summarize-as-Note into a header kebab (disabled until the
  conversation has messages)

Left sidebar:
- Full-width "+ New Chat" primary button
- Search input filters conversations by title (case-insensitive
  substring) with a "no matches" empty state
- Move Select into a sidebar kebab next to search

Shared .btn-kebab + .kebab-menu styles give future conv-level actions
a home. Document mousedown + Escape dismiss both kebabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:28:48 -04:00
bvandeusen f0c93ffa3b fix(chat): drop display:flex from .chat-panel-fill so grid layout wins
The inherited .chat-panel-fill class lands on ChatPanel's new .chat-full
root via attribute inheritance, and its display:flex was overriding the
grid layout — stacking messages, sidebar, and input bar vertically
inside the reading column instead of placing the sidebar in grid col 4.
Layout is ChatPanel's responsibility now; .chat-panel-fill only sizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 07:40:44 -04:00
bvandeusen ad4fe3d783 feat(chat): center chat reading column and full-height context sidebar
Replaces the full-variant flex layout with a single CSS grid owning the
messages column, input bar, and context sidebar. Messages and input bar
share a centered ~820px reading track (--chat-reading-width token) so
the mic button can't get pushed off-screen on wide monitors, and the
context sidebar spans grid-row 1/-1 so it runs the full height from the
chat header down to the bottom edge instead of stopping above the input
bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 07:11:08 -04:00
bvandeusen dcbe018297 Merge pull request 'Release v26.04.15.1 — Dynamic voice silence threshold + filled mic halo' (#35) from dev into main 2026-04-15 04:38:27 +00:00
bvandeusen 36fb71699b feat(voice): dynamic silence threshold + filled red mic halo
The web silence detector previously ran RMS over getByteFrequencyData
bytes (which are already dB-scaled) and re-log'd the result, producing
numbers that didn't line up with real dBFS. Combined with a static
-40 dB threshold that sat right on top of room ambient, silence
detection rarely fired and the user had to click stop manually.

- Rewrite useSilenceDetector to use getFloatTimeDomainData for honest
  linear RMS → dBFS.
- Silence threshold is now dynamic: track session peak dBFS and treat
  "silent" as 15 dB below peak. Auto-calibrates per mic/room.
- Grace period (1500 ms) at start so the user can begin speaking
  before checks arm; static -45 dB fallback until peak clears -25 dB
  so dead-silent sessions don't spin forever.
- Silence duration bumped 1500 → 2000 ms for breathing room.

Visual: detached red radial-gradient disc sits behind the mic in a
wrapper; the button no longer scales, so the white mic icon stays
legible on top while the halo grows from 1x to ~2.6x with live
amplitude. Much more obvious "you are live" signal than the prior
subtle box-shadow pulse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:26:52 -04:00
bvandeusen 027fbec606 Merge pull request 'Release v26.04.14.3 — Live amplitude mic pulse' (#34) from dev into main 2026-04-15 02:54:42 +00:00
bvandeusen 730dbfaf7b feat(voice): pulse mic button with live amplitude
The mic button now scales and glows proportional to the silence
detector's RMS amplitude instead of sitting static. Gives obvious
feedback that audio is actually being picked up — silence still
breathes via a 0.1 floor, loud input caps at ~1.18x scale so it
doesn't punch through the input bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:46:37 -04:00
bvandeusen 267a975455 Merge pull request 'Release v26.04.14.2 — Discuss failure mode fixes' (#33) from dev into main 2026-04-15 01:45:36 +00:00
bvandeusen 7bd1548f71 fix(discuss): hard-fail empty articles and skip RAG on seed turn
Discuss flow was hallucinating unrelated content when article
extraction returned empty or RAG pulled in orphan notes that looked
more relevant than the generic seed prompt.

- seed_article_discussion raises EmptyArticleError on empty body;
  briefing and /news routes return 422 instead of staging an empty
  synthetic tool result.
- build_context skips RAG auto-injection when user_message matches
  ARTICLE_DISCUSS_SEED so the article IS the context on turn one;
  follow-up turns keep RAG on.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 18:13:17 -04:00
bvandeusen 9f3b3450fa Merge pull request 'Release v26.04.14.1' (#32) from dev into main 2026-04-14 12:02:13 +00:00
bvandeusen ba90ad8132 feat(article-discuss): unify /news + briefing entry points, persist summaries to RAG
Both the /news discuss button and the briefing discuss button now call a
shared seed_article_discussion() helper that stages the synthetic
read_article tool exchange and the conversational seed prompt — behavior
stays byte-identical across entry points. /news also auto-starts
generation so the chat screen lands on an in-flight stream.

First assistant reply in a seeded article conversation is persisted as a
Note (tags: article-summary + article topics) and backlinked via
rss_items.discussion_note_id, so the knowledge base stops being amnesiac
about articles the user has engaged with.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 07:54:24 -04:00
bvandeusen 9157740069 ci: gate typecheck/lint/test on ref so main merge commits skip work
Forgejo Actions doesn't consistently honor `on.push.branches` as a
filter — the merge commit landing on main after a release PR was still
triggering the full CI workflow on a SHA that had already passed on
dev, burning ~1 minute of runner time for no reason.

The `build` job already had a ref guard, so no duplicate images were
being pushed, but typecheck/lint/test ran unconditionally. Add the
same guard (`refs/heads/dev` or `refs/tags/v*`) to those three so main
pushes trigger the workflow but every job skips immediately with zero
runner time.

Also tightened the build job's tag filter from `refs/tags/` to
`refs/tags/v` for consistency with the new guards and to avoid ever
building from a non-version tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:40:41 -04:00
bvandeusen fd885c1bc8 Merge pull request 'Release v26.04.13.3' (#31) from dev into main 2026-04-14 03:36:09 +00:00
bvandeusen 8205590f8d feat(briefing): cache + map-reduce article context for rich discuss chats
The Discuss button on news cards was producing one-shot replies because
the model got the whole trafilatura blob dropped into history with a
canned "summarize and discuss this article" prompt — no length guard, no
prep, no invitation to converse. Large articles got silently truncated by
Ollama; small articles got a tepid reply.

This reworks discuss_article around a three-layer cache:

  context_prepared  →  content_full  →  fresh trafilatura fetch

First click on a small article fetches once, writes through to both
caches, and passes the body straight into the synthetic read_article
tool-result. First click on a large article additionally runs a parallel
map step (services/article_context.py) that chunks the body on paragraph
boundaries, summarizes each ~8k chunk to ~300 words of dense factual
prose via the background model, and concatenates the summaries under
section headers — all pinned to num_ctx=16384 so the map step doesn't
itself fall victim to silent truncation. Repeat clicks on either path
skip straight to the chat turn.

The canned summary prompt is replaced with a conversational seed that
invites the user into an actual discussion rather than a one-shot
synopsis, matching the goal of "have a conversation about an article,
not just read it."

discuss_topic is intentionally left untouched — it's the multi-article
aggregation path and needs a separate rework. Follow-up task will decide
whether to retire it or rework it on the cached-context approach.

Closes task #106.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 20:52:00 -04:00
bvandeusen 939b910372 Merge pull request 'Release v26.04.13.2' (#30) from dev into main 2026-04-13 23:00:33 +00:00
bvandeusen 70cea78c2f fix(llm): default generate_completion num_ctx to Config.OLLAMA_NUM_CTX
Non-streaming generate_completion was the only LLM entry point that
didn't default num_ctx — stream_chat and stream_chat_with_tools both
fall back to Config.OLLAMA_NUM_CTX (16384). When a caller omitted the
argument, Ollama silently used the model's default window (~4k on
qwen3) and truncated the prompt.

That footgun was masked by fallback paths in the research pipeline:
_generate_outline's prompt carries ~12 sources × 2000 chars (~6k
tokens) of source material plus a system prompt, so the prompt got
chopped, the model never saw the sources, JSON parsing failed twice,
and run_research_pipeline dropped into the single-note "monolith"
fallback (research.py:251). The user reported chat 215 producing such
a monolith note for a multi-source research topic.

Two-layer fix:
- Default num_ctx to Config.OLLAMA_NUM_CTX inside generate_completion,
  matching the streaming entry points. Any current or future caller
  that forgets the argument stops silently losing input.
- Pin num_ctx=16384 explicitly in _generate_outline and
  _generate_executive_summary with comments pointing at the failure
  mode, so a refactor of the generate_completion default can't
  silently regress the research pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:20:58 -04:00
bvandeusen 4e4dbb8783 fix(chat): feed title model raw turns instead of post-build_context messages
_generate_title was receiving the full messages list from build_context,
which prepends RAG snippets, RSS excerpts, URL content, and briefing
article dumps INTO the user-role message string. The role=="user" filter
inside _generate_title then handed that composite blob (capped at 300
chars) to gemma3:4b as "the user's message", so the background model
was titling conversations based on article excerpts instead of what the
user actually typed — producing wildly wrong titles like "Briefing
Profile Preferences & Schedule" for a plain calendar query. See #109.

Pass the raw history + user_content + assistant reply instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:15:39 -04:00
bvandeusen e4e1d1da49 fix(tz): interpret calendar and briefing dates in user's local timezone
Two related bugs where the server defaulted naive datetimes to UTC instead
of the configured user timezone, causing all-day events to land on the
previous day and briefings to "disappear" at UTC midnight.

- New services/tz.py helpers: get_user_tz, user_today, user_briefing_date
  (the briefing day flips at 4am local to align with the compilation slot,
  so the 00:00-04:00 local window still shows yesterday's briefing until
  the new one is generated).
- calendar create/list/update tools now parse naive datetimes in the
  user's TZ before converting to UTC for storage, and tool descriptions
  tell the model to pass plain local dates.
- briefing_conversations.get_or_create_today_conversation and the
  reset-today route use user_briefing_date so the in-progress briefing
  doesn't get replaced at 19:00 NY / UTC midnight.
- _run_profile_closeout targets user-local "yesterday" for consistency.

Regression tests added for the TZ helpers and the calendar tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 15:35:27 -04:00
bvandeusen af6f81e0a7 Merge pull request 'Release v26.04.13.1' (#29) from dev into main 2026-04-13 05:13:41 +00:00
bvandeusen 734ccc337f test(llm): lock in _should_think classifier; drop briefing think overrides
Adds 38 parametrized tests for the _should_think classifier covering the
explicit-override path, empty/whitespace content, short/medium/long length
boundaries, case-insensitive keyword matching, and a chatty-message negative
set. These pin the content-based semantics so future tweaks to the keyword
list or length thresholds surface regressions immediately instead of going
unnoticed behind subtle latency changes.

Also drops the `think=True` overrides from the briefing /discuss-article
and /discuss-topic entry points. With `"discuss"` added to _THINK_KEYWORDS,
those canned prompts trip the classifier naturally, so the overrides were
redundant — keeping a uniform "classifier is authoritative" rule makes the
code easier to reason about.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 01:04:18 -04:00
bvandeusen 87fcaa6a0d fix(chat): gate qwen3 thinking on message content instead of always-on
The frontend hardcoded think=true on every chat send (ChatPanel full +
widget variants, KnowledgeView minichat), which defeated the _should_think
gate on the backend and made qwen3:14b spend 5-20s on chain-of-thought
reasoning for every turn — even "hi". This was the root cause of the
warm-path TTFT variance tracked in followup_ttft_variance.md: the logged
ttft_ms was really prefill + full thinking phase, bouncing with the depth
of the model's reasoning, not with cache or eviction.

All three frontend callers now pass think=false and let _should_think be
authoritative. The classifier is now a real content-based gate: explicit
think_requested=True still forces on as an override (briefing discuss
actions, future UI toggles, MCP callers), otherwise messages <80 chars
without reasoning keywords skip thinking, messages >=400 chars or
containing keywords like why/explain/analyze/debug/review/etc. get it.

Generation timing now separately records think_requested, the final
think decision, first_token_ms (first any chunk), and thinking_ms
(duration of the thinking phase). ttft_ms keeps its existing semantic
(first content token) so existing log analysis still works. The timing
log line surfaces all four fields so the old "just a big ttft number"
ambiguity is gone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:53:47 -04:00
bvandeusen 782f36ed51 fix(llm): surface Ollama error body; refresh pre-gemma3 summaries
Two small hardening fixes from the mistral-nemo testing round:

1. stream_chat / stream_chat_with_tools now read the Ollama response
   body and log it before raising on non-2xx. Previously all we saw
   was 'HTTP 400 Bad Request' — the gemma3-no-tools failure would
   have been diagnosed in one step if we'd been logging the body,
   which says e.g. 'model does not support tools'.

2. backfill_project_summaries() now also targets summaries stamped
   before 2026-04-12 (the gemma3:4b cutover). The remaining projects
   still carrying the broken qwen2.5:3b output (token repetition,
   hallucinated topics) will regenerate on next startup on the
   better model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 22:13:21 -04:00
bvandeusen 9a851de624 fix(llm): normalize Ollama model tags to lowercase
Ollama's /api/tags returns whatever casing was used at pull time
(e.g. 'gemma3:12B' if the user ran 'ollama pull gemma3:12B'), but
/api/chat rejects mixed-case tags with a 400. The two code paths
are inconsistent, which surfaces the capitalized tag in the model
dropdown and then silently kills every chat request against it.

Lowercase on read (get_installed_models), on settings write
(update_settings_route), and on ensure_model() input so a legacy
mixed-case user setting can't trigger a spurious re-pull at
startup. The dropdown and stored settings are now always in the
form Ollama will actually accept.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 18:02:52 -04:00
bvandeusen 95135a665b fix(llm): switch default background model to gemma3:4b
qwen2.5:3b produced broken auto-summaries (misspellings, token repetition,
hallucinated topics) — its synthesis ceiling is too low for free-form
summarization. Gemma 3 4B is stronger on summarization at similar size
and still fits comfortably alongside the main chat model in VRAM, so it
preserves the KV-cache-separation strategy that keeps chat TTFT fast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:45:22 -04:00
bvandeusen 46c6a9f174 Revert "fix(projects): route auto-summary through main model"
This reverts commit ec3853a78a.
2026-04-12 15:40:04 -04:00
bvandeusen ec3853a78a fix(projects): route auto-summary through main model
Project auto-summaries were using the 3B background model, but the
task — synthesizing a coherent paragraph over ~10 notes — is well past
what 3B can do reliably. Evidence on dev: "doging conversation
hygiene", "MCPview). MCP).", trailing stray quotes, and hallucinated
topics ("AI regulation").

Route through the user's default chat model instead. Project summary
regeneration is rare (only when a project changes) so the KV cache
eviction cost on the main model is negligible.

Title generation, tag suggestions, and RSS classification continue to
use the background model — those tasks are within what 3B handles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:38:00 -04:00
bvandeusen 0becc1439b fix(llm): correct context sizing, honor think requests, broaden delete
Three related fixes uncovered while benchmarking qwen3:14b against 8b:

- pick_num_ctx was only counting message content, missing the ~15K
  tokens of tool schemas. num_ctx=8192 was being selected while actual
  prompt_tokens hit 14K+, causing silent prompt truncation on every
  tool-using request. Now includes json.dumps(tools) in the estimate.
  KV cache priming in app.py and routes/settings.py also fetches tools
  so the primed num_ctx matches what real chat requests will use.

- _should_think's heuristic classifier was overriding explicit
  think=true requests from the frontend toggle and MCP, gating on
  message length and regex patterns. Now a pass-through — the caller
  is the source of truth. quick_capture hardcodes think=False since
  it's a fast classification path that was relying on the old gating.

- delete_note description only mentioned "note or task", so the model
  refused to call it for entries created by save_person / save_place /
  create_list. Description now explicitly lists all five note_types it
  handles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:32:52 -04:00
bvandeusen a6fe1c0d7c docs: update architecture and features for tool consolidation
Update tools.py references to tools/ package, remove stale intent
router section, update research pipeline to multi-note output,
fix create_task references now merged into create_note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 13:56:13 -04:00
bvandeusen 77339d5c58 refactor(tools): consolidate LLM tools from 42 to 38
Merge create_task into create_note (set status='todo' for tasks, omit
for notes), merge delete_task into delete_note, consolidate entity
tools (create/update_person → save_person, create/update_place →
save_place), rename get_note → read_note with clearer descriptions,
move calculate out of rag.py into utility.py, and extract shared
duplicate detection into check_duplicate() helper.

Updates all downstream references in generation_task.py, quick_capture.py,
ToolCallCard.vue, and WorkspaceView.vue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 13:38:45 -04:00
bvandeusen e95ad90055 fix(research): show exception type when error message is empty
Some exceptions (e.g. connection errors) produce empty str(e),
resulting in "Research failed: " with no explanation. Fall back to
the exception class name when the message is blank.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 12:28:57 -04:00
bvandeusen 1d6905ccc8 feat(research): linked section notes + executive summary in index
Research pipeline now produces an index note with:
- Executive summary (2-3 paragraphs synthesized from sections)
- Clickable links to each section note (/notes/{id})
- Section notes have parent_id pointing to the index

Also improves outline resilience: lowered minimum sections from 3
to 2, retries once on failure before falling back to monolithic note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 12:18:31 -04:00
bvandeusen 403eb49de0 fix(chat): prevent New Chat button from stretching full height
The .btn-new-conv class has flex:1 for the sidebar row layout, but
when reused inside .no-conversation (a column flex), it stretched
vertically to fill the entire viewport — appearing as a giant
purple rectangle. Override with flex:none in the no-conversation
context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 12:06:44 -04:00
bvandeusen 717ac1b5d7 fix(ci): make registry cache export non-fatal
The Forgejo registry occasionally returns 400 on large cache layer
blob uploads, failing the entire build even though the image itself
pushed successfully. Adding ignore-error=true to cache-to so cache
failures don't block deployments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:36:27 -04:00
bvandeusen 8276e5050a fix(chat): let backend auto-generate conversation titles
KnowledgeView and WorkspaceView were passing explicit titles
("Knowledge chat", "Project — Workspace") to createConversation(),
which prevented the backend's auto-title generation from firing
(condition: `not conv_title`). Pass no title so the background
title generator names conversations after their first message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:33:53 -04:00
bvandeusen 436e339c48 fix(knowledge): task cards showed 'LIST' badge instead of 'Task'
The type-badge template had no v-else-if for task, so tasks fell
through to the v-else which renders 'List'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 10:07:23 -04:00
bvandeusen c32c75f77e fix(test): update mock path for tool registry refactor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 10:02:34 -04:00
bvandeusen ce2d76447c refactor(tools): decorator-based tool registry replaces monolithic tools.py
Split 2566-line tools.py into a tools/ package with @tool decorator
registration. Each tool's schema, metadata, and implementation live
together. Briefing eligibility is now a briefing=True flag instead of
a separate frozenset allowlist. Conditional inclusion (CalDAV, SearXNG)
uses requires= metadata. Public API (get_tools_for_user, execute_tool)
unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 10:01:00 -04:00
bvandeusen 5275be8588 fix(ci): install setuptools before http-ece for uv
uv creates bare venvs without setuptools (unlike pip). http-ece
doesn't declare setuptools as a build dependency but needs it when
built with --no-build-isolation.
2026-04-12 00:10:45 -04:00
bvandeusen 8d07b6c79e ci: new ci-runner base image + uv/ruff/node_modules cache
Runner base image (infra/Dockerfile.runner-base):
- Added uv (fast Python package installer, ~10x faster than pip)
- Added ruff (baked in, lint job drops from ~13s to ~2s)
- Added jq + tzdata
- Renamed tag from py3.12-node22 to ci-runner (purpose over contents)

Workflow (ci.yml):
- typecheck: cache node_modules directly instead of npm download cache;
  skip npm ci entirely on cache hits (9m41s → ~30s expected)
- lint: just `ruff check src/` — no venv, no install
- test: uv venv + uv pip install replaces pip (~10x faster resolves)
- all jobs: runs-on ci-runner label

Baseline: typecheck 9m41s, lint 13s, test 1m42s
2026-04-11 23:59:57 -04:00
bvandeusen 02138f5728 fix(ci): use venv for ruff install — pipx not on runner image
The py3.12-node22 runner doesn't ship pipx, so the previous commit's
pipx install ruff failed with command not found. Switched to the same
venv pattern the test job uses.
2026-04-11 16:31:36 -04:00
bvandeusen 61f95fb9ed style(knowledge): align task card theming with note/list pattern
Task card was the outlier in the Knowledge view: half-width fade-to-
transparent gradient bar while note/list use full-width two-stop
gradients, and the type badge was purple while the border+bar were gold.

- k-card--task::before: full width with gold→amber gradient (#d4a017
  → #fbbf24) matching the two-stop pattern
- badge--task: switched from purple to amber so the badge matches
  the card chrome
2026-04-11 16:30:36 -04:00
bvandeusen cc74901761 ci: add concurrency, registry cache, pipx ruff, least-privilege perms
- concurrency: cancel in-progress runs on new pushes (non-tag only);
  tag runs are never cancelled so releases can't kill themselves
- permissions: contents: read default, packages: write scoped to build
- docker build: explicit cache-from/cache-to against :cache tag so
  BuildKit warmth survives local runner cleanup
- lint: pipx install ruff instead of --break-system-packages
- actions/cache@v3 → @v4
- triggers main branch pushes for gate runs (build stays dev/tag only)
- comments added on the non-obvious bits (http-ece isolation, docker
  prune two-step, registry cache mode=max)
2026-04-11 16:11:27 -04:00
bvandeusen 9f6608fc8f fix(briefing): stale weather auto-refresh + task filter + overdue prompt
Three briefing quality fixes surfaced by reading today's 2026-04-11
compilation output:

- **Stale weather**: get_weather was returning 48h-old cache data
  after a missed scheduler run. Tool now auto-refreshes any cached
  location older than 6h (fetching fresh data from Open-Meteo), and
  stamps each location with cache_age_hours + is_stale so the model
  can hedge instead of faithfully relaying old numbers.

- **Cancelled tasks leaking into prose**: briefing loop now defaults
  list_tasks calls to status=["todo","in_progress"] when the model
  doesn't specify, so cancelled/done items stop showing up in the
  summary. Localized to the briefing path — chat still sees full
  history.

- **Overdue in-progress tasks missed by midday check-in**: tightened
  the check-in prompt to explicitly require two list_tasks calls —
  one for in_progress (catches items dragging past their due date)
  and one filtered by due date — so long-running tasks stop getting
  silently dropped.
2026-04-11 13:32:52 -04:00
bvandeusen e63b4634ea feat(briefing): surface news items in compilation via get_rss_items
The compilation prompt mentioned "news themes" but didn't name the
tool, and the model was never calling get_rss_items. Result: today's
briefing had zero news coverage despite the tool being wired up and
in the allowlist.

- Explicitly list the tools to call in the compilation prompt so
  get_rss_items gets invoked alongside list_tasks/list_events/get_weather.
- When the model calls get_rss_items during a compilation run,
  intercept and return the already-scored/filtered items (topic prefs
  + reaction-weighted) instead of the raw feed dump execute_tool
  would return. Aligns the model's view of news with the sidebar's
  rss_item_ids metadata.
2026-04-11 13:30:26 -04:00
bvandeusen 593d737e26 feat(briefing): UI polish for agentic briefing messages
Three visual improvements for the briefing conversation:

1. Intermediate tool-call messages (empty content, briefing_intermediate:
   true) now render as a compact dashed status row with per-tool pills
   showing result counts, instead of an empty assistant bubble followed
   by a stack of ToolCallCards. Click to expand the full cards.

2. Slot badge on non-intermediate briefing messages — "Full Briefing"
   for compilation, "Morning/Midday/Afternoon Update" for slot
   injections. Slot updates get a softer bubble treatment (transparent
   background, muted border, smaller text) so the compilation stays
   visually dominant.

3. Slot separator now triggers on briefing_slot metadata (not the
   compilation-only rss_item_ids), and uses a look-behind so it only
   fires when there's a prior slot message — no separator above the
   first briefing of the day.

New component: BriefingToolStatusRow.vue handles the intermediate
pill row and delegates to ToolCallCard when expanded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 22:10:31 -04:00
bvandeusen 06dbc0e27a test(briefing): drop tests for removed legacy helpers
format_task, compute_task_hash, and split_changed_tasks were deleted
in 9eba6ac when the legacy one-shot briefing path was ripped out.
Their tests went with them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:48:37 -04:00
bvandeusen 7e0938fe7d feat(fable-mcp): add reset_today_briefing tool
Wipes today's briefing messages (keeps the conversation row) and
optionally re-fires the compilation slot to regenerate. Pairs with
the new POST /api/briefing/reset-today backend route.

Also drops the stale briefing_mode reference from trigger_briefing's
docstring now that legacy mode is gone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:42:48 -04:00
bvandeusen 9eba6ac107 refactor(briefing)!: remove legacy one-shot synthesis, agentic-only
Deletes ~760 lines of legacy briefing code: format_task, compute_task_hash,
upsert_task_snapshots, _gather_internal, _gather_weekly_review,
_llm_synthesise, and the unified prompt helpers. run_compilation and
run_slot_injection are now agentic-tool-use-loop only.

briefing_scheduler and user_profile migrated from the deleted helper to
services.llm.generate_completion (retry + keep_alive baked in).

routes/briefing.manual_trigger now persists agentic tool-call receipts
via _persist_agentic_messages (previously silently dropped them) and
adds POST /api/briefing/reset-today to wipe today's briefing messages.

BREAKING: briefing_mode setting no longer honored; no legacy fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:42:42 -04:00
bvandeusen 66f906a629 fix(briefing): exclude paused-project tasks from briefing tooling
list_notes gains exclude_paused_projects; list_tasks tool sets it when
no explicit project filter is given. Paused projects no longer leak
tasks into briefings or list_tasks results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:42:33 -04:00
Bryan Van Deusen 5f4759a5e8 feat(chat): ground factual claims in tool results, be honest when empty
Applies the grounding discipline from the agentic briefing work to the
main chat system prompt. The regular chat pipeline was already agentic
(it uses stream_chat_with_tools), but its system prompt never told the
model "only assert facts from tool results" or "if a tool returns
nothing, say so honestly." That left room for the same class of
hallucinations the briefings had — calling list_events, getting an
empty array, and then confidently mentioning a meeting anyway.

Adds two new static rules to the tool guidance block in llm.build_context:

GROUNDING — when the user asks about their own data, call the relevant
tool to see what exists. Never assert from memory or assumption.

HONESTY WHEN EMPTY — if a tool returns empty results, tell the user
plainly. No fabricated example items, no invented meetings, no generic
suggestions dressed up as real data.

Both rules are in the static (KV-cache-stable) portion of the system
prompt so they cost nothing on repeated requests for the same user.

Carries the hallucination fix from the briefing work directly into
every chat turn, not just chat that happens inside a briefing thread.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:54:01 -04:00
Bryan Van Deusen 25c767ddf2 fix(test): update run_slot_injection mock for new tuple return
PR 1.5 (commit 4168167) changed run_slot_injection's signature from
returning str to returning tuple[str, dict] so the scheduler can get
at the agentic message list for receipt persistence. The
test_run_slot_morning_runs_on_work_day mock was still returning the
old plain-string value, which unpacked to a ValueError at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:22:06 -04:00
Bryan Van Deusen 7e06e58ab6 chore(fable-mcp): bump version to 0.2.6 for briefing introspection tools
Manual bump because the pre-commit hook that normally handles this
didn't run — its scripts had lost their executable bits earlier in
the session. Permissions have been restored in this commit's tree,
so subsequent commits touching fable-mcp will auto-bump again.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:07:54 -04:00
Bryan Van Deusen 2fd914916f feat(fable-mcp): briefing introspection and manual trigger tools
Adds the MCP tools needed to debug the agentic briefing pipeline
without waiting for scheduled slots to fire:

- fable_list_briefings — list briefing conversations newest first
- fable_get_today_briefing — fetch today's briefing with all messages
- fable_get_briefing_messages(conv_id) — message list for a specific
  briefing conversation, including tool_calls with embedded results
- fable_trigger_briefing(slot) — manually run a slot via
  POST /api/briefing/trigger (fires RSS/weather refresh, same as
  the scheduler)
- fable_get_conversation(conv_id) — generic conversation read for
  chat or briefing threads, full messages + tool_calls + metadata

All of these hit existing REST endpoints (/api/briefing/conversations,
/api/briefing/conversations/<id>/messages, /api/briefing/trigger,
/api/chat/conversations/<id>) so no API surface changed — the gap
was purely on the MCP side. Bearer token auth works across both
blueprints because login_required already accepts API keys.

Enables a full "trigger → inspect → tune prompt → repeat" loop from
Claude without touching the UI, which is necessary for iterating on
agentic briefing prompts when the scheduled slot is hours away.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:06:55 -04:00
Bryan Van Deusen 4168167f24 feat(briefing): agentic slot injections + persist tool-call receipts
PR 1.5 + PR 2 of the agentic briefing rollout. Extends the agentic
path to cover the morning/midday/afternoon check-in slots in addition
to the 4am compilation, and persists the full tool-call sequence
into the briefing conversation so chat follow-ups see the receipts.

run_slot_injection now honors the briefing_mode setting the same way
run_compilation does: agentic mode routes through run_agentic_briefing
with a check-in system prompt variant, legacy falls back automatically
if the new path returns empty. Signature changed from str to
(str, dict) to surface the agentic message list to the caller.

_agentic_system_prompt now takes the user's local-day window and
timezone, pre-computed by run_agentic_briefing, and embeds them in
the prompt. This eliminates a whole class of "wrong day" bugs that
would otherwise happen when the model tried to translate "today" into
ISO 8601 ranges without knowing the user's timezone.

briefing_scheduler._run_slot_for_user now calls _persist_agentic_messages
after each slot, which walks the agentic message list and stores
every intermediate assistant turn (with its tool calls and results
folded into the flat storage format the existing chat loader expects)
as a real message row. The synthetic "[Midday briefing update]"
user-role messages are no longer created — final prose is written
with metadata.briefing_slot so the UI can still identify it as a
scheduled entry. The agentic user-trigger ("Generate my morning
briefing…") is deliberately skipped so it doesn't recreate the same
fake-user-message problem we're trying to remove.

briefing_conversations.post_message now accepts tool_calls, matching
the schema the Message model already supports. This lets scheduled
briefings write structured tool-use history without reaching into
the model layer.

Net effect with briefing_mode="agentic" on:
- All four slots are grounded in tool results, no more hallucinated
  events or tasks
- Chat follow-ups in the briefing conversation see morning's
  list_events → [] receipt (and everything else), so "what meeting?"
  gets an honest "nothing on the calendar" reply grounded in data
- No more fake [Briefing update] user messages in the chat scroll

Still to come (PR 3): UI polish — tool-call status row, inline cards
for list_events/list_tasks results, visual treatment for slot messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:01:23 -04:00
Bryan Van Deusen 22a13636e8 fix(events): null end_dt no longer matches events outside the window
services/events.py::list_events treated any event with end_dt IS NULL
as perpetually matching, so a point-event created last week would be
returned as "happening today" whenever the briefing or list_events
tool queried today's window. That manifested as briefings claiming
past events were scheduled for the current day.

Split the null-end branch: point events now only match when start_dt
falls within [date_from, date_to]. Events with an end_dt continue to
use standard overlap logic.

This bug affected both the legacy briefing synthesis and the agentic
list_events tool path, since both call the same events service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:01:01 -04:00
Bryan Van Deusen aebb6baa2c feat(briefing): agentic compilation path behind feature flag (PR 1/N)
First cut of the agentic briefing redesign. Morning compilation can now
route through a tool-call loop that grounds every factual claim in an
actual tool result, eliminating the hallucinated meetings, tasks, and
news items the legacy one-shot path was producing. Behind a per-user
`briefing_mode` setting (default "legacy"); falls back to the legacy
path automatically if the new path returns empty (e.g. model too weak
to drive tool calls reliably).

New: services/briefing_tools.py — explicit read-only allowlist of 10
tools (tasks, events, weather, rss, projects, notes). New tools added
to tools.py must be opted in by name. Excludes all mutating tools and
external search tools (search_images, search_web, research_topic) which
are neither useful nor safe for a scheduled background job.

New: briefing_pipeline.run_agentic_briefing — wraps the existing
stream_chat_with_tools loop with slot-specific system prompts that tell
the model to only assert facts from tool results and to be honest when
tools return nothing. Max 8 rounds, per-round exception handling,
returns the full message list so tool-call receipts can be persisted
alongside the prose in a later PR.

Sentence-count floors bumped: compilation 6–10 (was 4–8), check-ins
3–5 (was 2–3). Weekly review 5–8.

Design doc: docs/2026-04-10-agentic-briefing-design.md

Out of scope for this PR (future PRs): slot-injection migration,
persisting tool-call receipts into the conversation so chat follow-ups
see them, UI polish for tool-call status, sidecar storage for
briefings. See the design doc's migration path for details.

Enable on an account with:
  UPDATE settings SET value='agentic'
  WHERE user_id=<id> AND key='briefing_mode';
or insert the row if missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:13:55 -04:00
Bryan Van Deusen 3f3156db07 feat(ollama): configurable per-model keep_alive durations
Replace the hardcoded "2h" keep_alive everywhere with a helper that
returns OLLAMA_KEEP_ALIVE_MAIN (default 30m) for the interactive model
and OLLAMA_KEEP_ALIVE_BACKGROUND (default 10m) for the background
model. Lets the main model release VRAM during long idle periods
while keeping it warm enough for bursty chat use, and stops the
sporadic background model from camping VRAM it rarely needs.

Seven call sites updated to route through llm.keep_alive_for(model):
the streaming helpers, generate_completion, the two startup warmers,
the settings KV-cache primer, and the chat warmer endpoint.

Override via env vars: OLLAMA_KEEP_ALIVE_MAIN, OLLAMA_KEEP_ALIVE_BACKGROUND.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:13:32 -04:00
Bryan Van Deusen 102c0b74a0 feat(settings): tabbed MCP install instructions with Claude Code flow
Replace the hand-edit-JSON instructions in Settings → API Keys with a
tabbed UI (Claude Code / Claude Desktop / Other). The Claude Code tab
leads with the `claude mcp add` command, pre-filled with FABLE_URL and
the most recently generated API key, plus copy-to-clipboard buttons on
every snippet. Recommend `uv tool install` or `pipx install` over bare
`pip install` so fable-mcp reliably lands on PATH under PEP 668.

Also fix incorrect priority values in fable-mcp tool docstrings — the
enum is `none|low|medium|high`, not `low|normal|high`.

DRY pass: extract shared `copyToClipboard()` helper used by both
`copyApiKey` and the new snippet buttons; reuse existing
`btn btn-secondary btn-sm` for the copy buttons instead of a bespoke
class.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:35:50 -04:00
bvandeusen 8fb81bc1ed Merge pull request 'Release v26.04.10.1' (#28) from dev into main 2026-04-10 15:05:15 +00:00
bvandeusen c1bc73da8e fix(briefing): remove redundant current-conditions block; patch live temp into WeatherCard
The standalone current-conditions div showed just a bare temperature with no
forecast context, making the weather panel look incomplete. Now the live
temperature from /weather/current is patched directly into the WeatherCard's
current_temp so it stays fresh without a second display block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:43:30 -04:00
bvandeusen 3b8d40fea3 feat: overall completion bar on project cards; auto-collapse done milestones
ProjectListView: add an overall task completion bar above the per-milestone
bars showing the percentage of all project tasks that are done.

ProjectView: milestones where every task is complete now start collapsed
by default, keeping the view clean for projects with many finished stages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:38:02 -04:00
bvandeusen 2f5abc034e style: update email templates to Modern Fable visual identity
Replace indigo (#6366f1) with deep violet (#7c3aed) gradient header,
violet-tinted card border and background, refined border radius and
spacing, and violet-accented footer to match the app's design language.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:31:16 -04:00
bvandeusen 7a01733334 fix: coerce null project fields to empty string to avoid NOT NULL violation 2026-04-09 21:34:14 -04:00
bvandeusen 7d5611d00b fix: add 'paused' tab to project list filter 2026-04-09 19:33:50 -04:00
bvandeusen e5821ef3f8 fix: allow 'paused' in project status validation 2026-04-09 18:17:48 -04:00
bvandeusen 43231f44d2 fix(search): hybrid keyword + semantic search — exact title/body matches rank first 2026-04-09 12:28:55 -04:00
bvandeusen ab0b9c3199 fix(lint): add timezone import, use timedelta directly, remove unused variable 2026-04-09 08:46:34 -04:00
bvandeusen d12fc5d282 feat: paused project status with auto-pause (14 days), auto-reactivate on activity; fix back button context 2026-04-09 08:37:08 -04:00
bvandeusen 4c3d67994b fix: back button returns to project when note/task has a project, otherwise to Knowledge 2026-04-09 08:18:46 -04:00
bvandeusen 126bad7d99 fix(header): darken nav text in light mode — use text-secondary for inactive, primary-solid for active 2026-04-08 23:43:25 -04:00
bvandeusen 28fae75258 Merge pull request 'Release v26.04.09.1 — Briefing intelligence overhaul' (#27) from dev into main 2026-04-09 03:30:17 +00:00
bvandeusen 058bd76719 feat(briefing): multi-article topic discussion endpoint for deep thematic analysis 2026-04-08 22:53:23 -04:00
bvandeusen d56b57ee40 feat(briefing): cluster-level preference filtering — rank themes by user interest, suppress disliked topics 2026-04-08 22:51:13 -04:00
bvandeusen 8762552234 feat(briefing): project continuity — yesterday's activity and stale project warnings 2026-04-08 22:19:38 -04:00
bvandeusen 6cbf9be052 feat(briefing): weekly review — 7-day recap with task/note/project summary and upcoming preview 2026-04-08 22:18:19 -04:00
bvandeusen 3687fbeeb9 feat(briefing): differentiated check-in slots — midday progress nudge, afternoon wrap-up 2026-04-08 22:09:18 -04:00
bvandeusen a6543c1dc5 feat(briefing): cluster news by topic for thematic synthesis instead of flat headlines 2026-04-08 22:07:24 -04:00
bvandeusen 4558dd578a feat(briefing): weather × calendar cross-reference — rain warnings for outdoor events 2026-04-08 22:03:18 -04:00
bvandeusen 8647f52fbc feat(weather): live current conditions endpoint + polling display in briefing view 2026-04-08 21:33:27 -04:00
bvandeusen 304affb837 feat(briefing): daily planning — prioritized focus list and calendar gap analysis 2026-04-08 20:59:06 -04:00
bvandeusen 2e3f90384d feat(briefing): actionable intelligence — days overdue, project context, suggest next steps 2026-04-08 20:33:16 -04:00
bvandeusen 16b9ed9392 Merge pull request 'Release v26.04.08.3 — Type editors, calendar UX, DRY theme, contrast' (#26) from dev into main 2026-04-08 18:52:25 +00:00
bvandeusen f9c6802939 feat(calendar): linked start/end times, 1h default duration, smart rounding, past event hint 2026-04-08 13:47:15 -04:00
bvandeusen f2debd8a5b feat(knowledge): show organization for person cards, category for place cards
Exposes birthday, organization, address (person) and website, category (place)
from entity_meta in the knowledge API response; updates KnowledgeView cards
to display organization and category as visible meta chips/text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:32:15 -04:00
bvandeusen f8f14eea0f feat(editor): place form-first layout and list builder with Enter-to-add
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:30:26 -04:00
bvandeusen 64c19932a7 feat(editor): person form-first layout with structured fields and collapsible notes
When note type is 'person', replace the main TipTap editor with a contact card form
(Relationship, Birthday, Email, Phone, Organization, Address). The TipTap editor moves
to a collapsible 'Notes' section below the form, auto-expanded when existing body content
is present. Person fields are removed from the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:27:22 -04:00
bvandeusen 317d8f25d0 feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:25:07 -04:00
bvandeusen 87f74b1cd5 docs: add specialized note type editors implementation plan 2026-04-08 13:21:20 -04:00
bvandeusen f7fda0adca docs: add specialized note type editors design spec 2026-04-08 13:08:01 -04:00
bvandeusen 0ca39a2e34 feat(knowledge): redesign new item button as gradient CTA with icon menu for all 5 types 2026-04-08 12:34:52 -04:00
bvandeusen 16d9af8b96 fix(theme): increase dark mode contrast for borders, glows, and card edges; DRY color values into CSS variables 2026-04-08 12:22:03 -04:00
bvandeusen 2ac894b5d1 refactor(theme): DRY hardcoded violet values into CSS custom properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:19:38 -04:00
bvandeusen 7e81e50e3e feat(theme): sweep indigo to violet across all remaining views and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:12:46 -04:00
bvandeusen d8945536c4 Merge pull request 'Release v26.04.08.2 — Knowledge consolidation + Modern Fable identity' (#25) from dev into main 2026-04-08 15:40:12 +00:00
bvandeusen f30e90ef8d feat: narrator empty states, scroll fades, glow buttons, violet color sweep 2026-04-08 11:17:06 -04:00
bvandeusen 00f82f8cba feat(knowledge): card type DNA, violet hover bloom, amber timestamps, narrator empty states
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:14:31 -04:00
bvandeusen f953a36b86 feat(header): pill nav bar, brand shortening, status pulse, header gradient 2026-04-08 11:11:35 -04:00
bvandeusen 4bc7f9eaac feat(theme): shift palette from indigo to deep violet + muted gold
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:09:23 -04:00
bvandeusen 670de547ad docs: add Modern Fable visual identity implementation plan 2026-04-08 10:43:20 -04:00
bvandeusen a684d84edf feat: deprecate /notes and /tasks routes; redirect to Knowledge view 2026-04-08 10:35:21 -04:00
bvandeusen 34729cf1cf feat(knowledge): add task cards with status/priority/due-date display 2026-04-08 10:32:51 -04:00
bvandeusen dd16b39218 feat(knowledge): include tasks in knowledge queries and counts 2026-04-08 10:27:31 -04:00
bvandeusen 0bd362f582 docs: add Knowledge task consolidation implementation plan 2026-04-08 10:11:25 -04:00
bvandeusen 0afa2d8771 docs: add Modern Fable visual identity design spec 2026-04-08 10:04:55 -04:00
bvandeusen 93af4e6bab docs: add Knowledge view task consolidation design spec 2026-04-08 09:47:11 -04:00
bvandeusen 6e3de91a6f Merge pull request 'fix: resume AudioContext for silence detection' (#24) from dev into main 2026-04-08 12:37:16 +00:00
bvandeusen 6e0c528126 fix(voice): resume AudioContext to prevent suspended state blocking silence detection 2026-04-07 22:54:41 -04:00
bvandeusen dd3b59ed36 Merge pull request 'Release v26.04.07.3' (#23) from dev into main 2026-04-08 00:43:11 +00:00
bvandeusen 56b687c9f4 fix(voice): show mic button on HTTP; graceful error for non-secure contexts 2026-04-07 20:06:57 -04:00
bvandeusen 6d57840dc7 fix(voice): re-check voice status on tab visibility change and after saving voice settings 2026-04-07 20:01:47 -04:00
bvandeusen 7f37cee49f refactor(voice): integrate click-to-toggle mic into ChatInputBar; remove VoiceOverlay 2026-04-07 13:17:25 -04:00
bvandeusen 39e554d938 fix: rewrite title generator to only use user messages; bump background model to qwen2.5:3b 2026-04-07 12:53:33 -04:00
bvandeusen b3cf42863a fix: reinforce no-project-inference in system prompt; filter tool messages from title generation 2026-04-07 12:45:08 -04:00
bvandeusen d290bebad2 feat(stt): pass conversation context as Whisper initial_prompt to reduce mishearings 2026-04-07 08:48:27 -04:00
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
112 changed files with 11114 additions and 7145 deletions
+77 -35
View File
@@ -1,13 +1,27 @@
# CI runs first; build only proceeds if all checks pass.
#
# Push to dev: typecheck + lint + test build :dev + :<sha>
# Tag v* (release): typecheck + lint + test build :latest + :<sha> + :<version>
# Pull request: no CI run — code is validated on dev push before any PR is opened
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
#
# main pushes are NOT gated here: a merge to main only happens after
# dev has already passed CI, and the release tag is the sole trigger
# for a production image. Re-running CI on the merge commit just burns
# runner time without changing the outcome.
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
# The tag push triggers this workflow; build job pushes :latest + :<version>.
#
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
# gating on branch push is already enough.
#
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
# honor `on.push.branches` as a filter — merge commits landing on main
# still trigger the workflow, producing redundant runs on the same SHA
# that was already gated on dev. Every job therefore repeats the ref
# check so main pushes trigger the workflow but every job skips
# immediately (no runner time, no duplicate work).
#
# Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
@@ -28,8 +42,18 @@ on:
- "assets/**"
- "fable-mcp/**"
- ".forgejo/workflows/ci.yml"
# pull_request trigger intentionally omitted — all changes go through dev
# first, where CI already runs on push. PR runs would be redundant duplication.
# Cancel older runs on the same branch when a newer push lands. Tag runs
# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and
# are never cancelled, so a release build can't kill itself mid-flight.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
# Least-privilege default. Jobs that need more (build pushes to the
# registry) upgrade explicitly.
permissions:
contents: read
env:
REGISTRY: git.fabledsword.com
@@ -38,15 +62,18 @@ env:
jobs:
typecheck:
name: TypeScript typecheck
runs-on: py3.12-node22
# Skip on main merge-commit pushes — see workflow header comment.
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- name: Cache npm download cache
uses: actions/cache@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
path: ~/.npm
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: npm-cache-
- name: Install dependencies
run: npm ci
@@ -58,52 +85,57 @@ jobs:
lint:
name: Python lint
runs-on: py3.12-node22
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner
steps:
- uses: actions/checkout@v6
- name: Install ruff
run: pip install --break-system-packages ruff
# ruff is pre-installed in the ci-runner base image — no install
# step needed, lint runs in ~2s.
- name: Lint
run: ruff check src/
test:
name: Python tests
runs-on: py3.12-node22
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner
steps:
- uses: actions/checkout@v6
# Python 3.12 is pre-installed in the runner-base image (py3.12-node22).
- name: Cache pip wheels
uses: actions/cache@v3
- name: Cache uv packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('pyproject.toml') }}
restore-keys: pip-
path: ~/.cache/uv
key: uv-${{ hashFiles('pyproject.toml') }}
restore-keys: uv-
- name: Create virtual environment
run: python3.12 -m venv /opt/venv
run: uv venv /opt/venv
- name: Install package with dev deps
run: |
/opt/venv/bin/pip install --upgrade pip setuptools wheel
/opt/venv/bin/pip install --no-build-isolation http-ece
/opt/venv/bin/pip install -e ".[dev]"
# http-ece doesn't declare setuptools as a build dep, and uv
# creates bare venvs without it. Install setuptools first so
# --no-build-isolation can find it.
uv pip install --python /opt/venv/bin/python setuptools wheel
uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece
uv pip install --python /opt/venv/bin/python -e ".[dev]"
- name: Run tests
run: /opt/venv/bin/python -m pytest tests/ -v
run: /opt/venv/bin/python -m pytest tests/ -q
build:
name: Build & push image
needs: [typecheck, lint, test]
# Build on dev branch pushes and version tag pushes only.
# main branch pushes run CI for safety but do not build —
# the release tag (v*) is the sole trigger for a production image.
if: |
github.event_name == 'push' &&
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
runs-on: py3.12-node22
# Mirrors the ref guard on the gate jobs above — main merge-commit
# pushes skip here too, so no production image is ever built from a
# raw main push (only from the v* tag the release creates).
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
@@ -122,11 +154,14 @@ jobs:
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
- name: Free disk space
# Self-hosted runner housekeeping. Two-step cleanup:
# 1. Prune dangling containers/images globally (stops the runner
# from accumulating cruft from past failed builds).
# 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip
# mount cache survives but old intermediate layers don't
# accumulate indefinitely.
run: |
# Remove all unused images (including old :SHA tags) and containers.
docker system prune -af || true
# Keep the local BuildKit cache bounded so pip mount cache survives
# but stale intermediate layers don't accumulate indefinitely.
docker builder prune --keep-storage 5g -f || true
- name: Set up Docker Buildx
@@ -147,3 +182,10 @@ jobs:
provenance: false
tags: ${{ steps.tags.outputs.value }}
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
# Registry-backed layer cache. Pull from :cache to prime
# BuildKit, push updated layers back to :cache so the next
# build starts warm even if the runner's local cache was
# pruned. `mode=max` exports all intermediate layers, not
# just the final image, which is what gives the ~80% speedup.
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true
@@ -0,0 +1,34 @@
"""Add content_full and context_prepared caches to rss_items.
Revision ID: 0038
Revises: 0037
Create Date: 2026-04-13
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0038"
down_revision = "0037"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("rss_items", sa.Column("content_full", sa.Text(), nullable=True))
op.add_column(
"rss_items",
sa.Column("context_prepared", sa.Text(), nullable=True),
)
op.add_column(
"rss_items",
sa.Column("content_fetched_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("rss_items", "content_fetched_at")
op.drop_column("rss_items", "context_prepared")
op.drop_column("rss_items", "content_full")
@@ -0,0 +1,38 @@
"""Link rss_items to their discussion-summary note.
Revision ID: 0039
Revises: 0038
Create Date: 2026-04-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0039"
down_revision = "0038"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rss_items",
sa.Column("discussion_note_id", sa.BigInteger(), nullable=True),
)
op.create_foreign_key(
"fk_rss_items_discussion_note_id",
"rss_items",
"notes",
["discussion_note_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint(
"fk_rss_items_discussion_note_id", "rss_items", type_="foreignkey"
)
op.drop_column("rss_items", "discussion_note_id")
+216
View File
@@ -0,0 +1,216 @@
# Agentic Briefing — Design Spec
**Date:** 2026-04-10
**Status:** Proposed
**Author:** bvandeusen + Claude
---
## Problem
The current briefing pipeline hallucinates calendar events, tasks, and news items that do not exist in the user's actual data. Observed examples from production:
- A morning briefing asserting "your dentist appointment is still in progress" when no such event existed
- An 8am check-in mentioning "a quick meeting at 10:30 AM" with no backing calendar entry
- A midday check-in inventing "a team huddle at 2:30 PM" and "the Q2 budget draft due by Friday"
- The model calling `search_images` in response to a clarifying question about a fabricated meeting
These are not bugs in data retrieval — the data gathering code (`_gather_internal`, `_gather_external`) returns correct values. They are a structural consequence of how the briefing context is assembled.
## Root cause — the "no receipt" problem
The current `run_compilation` pipeline does this:
1. Python code gathers data (tasks, events, weather, news) from the database
2. Python formats the data into a structured text blob as the user-role message: `TODAY'S EVENTS: ...`, `DUE TODAY: ...`, etc.
3. The LLM is called **once** with `[system_prompt, user_prompt]` and produces prose
4. Only the prose reply is written to the conversation. **The underlying data is never persisted in the conversation history.**
When the user later chats in the briefing conversation, the chat endpoint loads the full conversation history — which contains the model's *prose* from earlier but not the data that prose was derived from. The model's own prior output becomes the only source of "truth" available for follow-up questions. If that prose asserted a fact (real or hallucinated), the model has no way to distinguish it from ground truth when generating the next reply, and it will double down.
Compounding factors:
- **Empty sections are silently omitted.** If `calendar_events` is empty, the user prompt contains no `TODAY'S EVENTS:` line at all. The model has no explicit "zero events" signal — combined with an imperative system prompt ("note calendar events and tasks"), it interprets the silence as "I should mention some" and fabricates.
- **Scheduled slot injections append synthetic turns.** `run_slot_injection` writes a fake `[Midday briefing update]` user message and the assistant reply into the persistent conversation. By evening, the chat history contains three separate briefings, each potentially with errors, all treated as equal-weight context on follow-up.
- **The `search_images` tool is available during briefing chat**, with a negative-instruction description ("Not for factual questions"). Small and mid-sized models frequently ignore negative guidance in tool descriptions and call the tool anyway.
## Solution — agentic briefing (the receipt model)
Replace the one-shot synthesis with a tool-call loop. The briefing is no longer "a text blob synthesized from pre-gathered data." It is **a scheduled agent run**: the LLM is given a system prompt, a curated set of read-only data tools, and a trigger ("generate the morning briefing"). The model must call tools to see what exists. Every tool call and tool result becomes part of the conversation history, where it lives as a permanent, structured receipt.
### Why this fixes the hallucination
When the model calls `list_events(today) → []`, that empty array is now a persistent message in the conversation. On a follow-up question like "what meeting?", the chat endpoint loads the conversation and the model sees its own tool result from the morning showing no events. Answering "you had a meeting at 10:30" would require the model to directly contradict a tool result sitting two messages back — something LLMs are much more reliable at avoiding than contradicting their own prior prose.
In short: **the model cannot fabricate what has a visible receipt proving it does not exist.**
### What the model sees — before vs after
**Before** — the model sees one structured blob and produces prose. The blob is discarded.
```
[system] You are a personal assistant... weave together what matters...
[user] Date: 2026-04-10
WEATHER: Home — partly cloudy, 816°C
DUE TODAY: Fix briefing hallucinations
OVERDUE (2 tasks): ...
[assistant] Morning! Looks like a quiet day with two overdue items...
```
**After** — the model is given tools and iterates to ground truth.
```
[system] You are the user's personal assistant giving their morning briefing.
Use tools to see what's relevant. Only mention things you learned from
tool calls. If a tool returns nothing, say so honestly.
[user] Generate the morning briefing.
[assistant] tool_call: list_tasks(filter="due_today")
[tool] [{"id": 81, "title": "Fix briefing hallucinations", ...}]
[assistant] tool_call: list_tasks(filter="overdue")
[tool] [{"id": 42, ...}, {"id": 43, ...}]
[assistant] tool_call: list_events(today)
[tool] []
[assistant] tool_call: get_weather(location="home")
[tool] {"description": "partly cloudy", "temp_min": 8, "temp_max": 16}
[assistant] tool_call: get_rss_items(max=8)
[tool] [...]
[assistant] Morning! Partly cloudy today, 8 to 16 — nothing on the calendar
so it's a clean run at the desk. Two things to keep in mind...
```
The conversation now contains verifiable receipts: `list_events(today)` returned `[]`, and that result sits in context forever (until pruned). Follow-up questions operate against those receipts.
## Architecture
### Existing infrastructure (reused, not rebuilt)
The codebase already has the agentic primitives — they're used for regular chat:
- **`llm.py::stream_chat_with_tools`** — the streaming tool-use loop that talks to Ollama with a `tools` parameter and yields tool-call chunks
- **`generation_task.py::_stream_with_retry`** — wraps `stream_chat_with_tools` with retry-on-500 behavior for cold-model races
- **`tools.py`** — defines 40+ tool schemas and an execution dispatcher
The briefing bypasses all of this and calls a one-shot `_llm_synthesise` helper. The refactor is mainly "route briefings through the same pipeline regular chat already uses."
### New modules
**`briefing_tools.py`** — a small wrapper exposing a curated read-only subset of `tools.py` for briefing runs. This is an **explicit allowlist**, not a blocklist, so newly-added tools must be opted in:
| Tool | Purpose |
|---|---|
| `list_tasks` (with filter args) | See what's actionable today, overdue, high priority |
| `list_events` (today / upcoming) | Know what's on the calendar |
| `get_weather` | Current/forecast weather |
| `get_rss_items` | Pull news themes filtered by user preferences |
| `list_projects` | Understand project context |
| `search_projects` | Surface active project summaries |
| `list_notes` (recent) | Capture follow-ups from yesterday |
**Explicitly omitted** from the briefing tool set:
- `search_images`, `search_web`, `research_topic`, `read_article` — external search is not a briefing concern, and `search_images` is the source of the "Peter Kyle Science Secretary" image-search bug
- All `create_*`, `update_*`, `delete_*` — briefings are read-only; a scheduled background job must not decide to mutate the user's data on its own
- `set_rag_scope`, `calculate` — not relevant to briefing content
### New briefing function
**`briefing_pipeline.py::run_agentic_briefing(user_id, slot, model, conversation_id)`** replaces `run_compilation`'s body (and eventually `run_slot_injection`). Internally:
1. Build a slot-specific system prompt (see below)
2. Load the curated briefing tools from `briefing_tools.py`
3. Seed messages with `[system, user]` where the user message is a simple trigger like `"Generate the morning briefing."`
4. Enter a tool-call loop (max 8 iterations):
- Call `stream_chat_with_tools`
- If the model returns `tool_calls`, execute them via the existing dispatcher, append tool results, continue
- If the model returns a final assistant message with no pending tool calls, break
5. Return `(final_prose, full_message_list, metadata)`
The full message list is important: it's written to the conversation along with the final prose, so the tool-call receipts become part of the persistent record.
### Slot-specific system prompts
Compilation (full morning briefing):
```
You are the user's personal assistant giving their full morning briefing.
Use the tools available to see what's actually relevant today — tasks due,
overdue items, events on the calendar, weather, news themes, project state
— and weave it into a warm, natural-sounding summary.
Rules:
- Call tools to see the data. Never assert facts you didn't learn from a tool.
- If a tool returns nothing (no events today, no overdue tasks), say so
honestly. Don't fabricate items to fill space.
- Write flowing prose. No markdown, no headers, no bullet points.
- Aim for 610 sentences. Skip topics that have nothing interesting.
- Close on one or two concrete, actionable suggestions.
User profile (for tone and preferences):
{profile_body}
```
Check-ins (midday, afternoon):
```
You are the user's personal assistant giving a brief {slot} check-in.
Use tools to see what's changed since this morning. Focus on progress
and what's still unaddressed.
Rules:
- Call tools to see current state. Never assert facts without tool results.
- If nothing meaningful has changed, say so briefly — don't invent progress.
- 35 sentences, natural prose, no markdown.
```
### Conversation hygiene — removing the fake user messages
The current scheduler appends two fake messages on every slot injection:
```python
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
await post_message(conv.id, "assistant", text)
```
Under the agentic model, we drop the fake `"user"` message entirely. Slot updates become plain assistant messages tagged with `metadata.briefing_slot = "midday"`. The chat endpoint's message loader is updated to filter these when building the LLM context on follow-ups, so a user chatting in the briefing conversation doesn't see three earlier briefings smashed into their history. The UI continues to show them as visible timeline entries.
(This is the Option A filter from the earlier discussion — a small, surgical change compared to migrating briefings to a separate sidecar table. Sidecar storage remains a possible future step.)
### Ollama setup compatibility
The existing Ollama deployment uses non-parallel mode across two GPUs, with a concern about context duplication between cards. This is the correct setup for agentic briefings:
- Each tool-call iteration shares the same KV cache on the same GPU, so appending tool results is cheap
- There is no race where a second iteration could land on a different card with a different cache state
- The trade-off is serialization: a briefing in progress will block a concurrent user chat request until it finishes, but scheduled briefings are rare enough (4× per day) that this is acceptable
If GPU contention becomes a problem later, the right lever is pinning specific models to specific GPUs (e.g., background tasks on GPU 1, interactive models on GPU 0) — not enabling parallelism.
## Cost & trade-offs
- **Latency:** a briefing now makes 57 inference calls (one per tool-call decision plus the final prose) instead of 1. On a local Ollama with a 7B model, expect 1540s per briefing vs the current 510s. Acceptable for a background job; if it becomes painful at the user-facing check-in slots, investigate letting the model batch independent tool calls in a single turn (Ollama supports this).
- **Model requirements:** the default model must be reliable at tool calling. `qwen2.5:7b`, `llama3.1:8b`, `mistral-small-3:24b`, and similar handle it well. Models in the ≤3B class typically fail — they either emit no tool calls and return empty prose, or hallucinate invalid tool arguments. If a user's `default_model` is too small, agentic mode should fall back to legacy mode with a log warning.
- **Context growth:** tool results bloat the conversation. At 8 tool calls per compilation × 4 slots per day, a daily briefing conversation can reach 20-30 KB of JSON-heavy history. Fine for a day; aged briefings should be archived/rolled up after 7 days.
- **Tool subset drift:** someone adds a new mutating tool to `tools.py` and forgets to update the briefing allowlist. Mitigated by the allowlist model — the default for new tools is "not exposed to briefings."
- **Infinite loop safety:** a buggy model could tool-call forever. Hard cap at 8 iterations, log a warning if hit, return whatever prose was last produced (or a fallback message).
## Migration path
Ship incrementally, each step independently reversible:
**PR 1 — Agentic compilation behind a feature flag.** Add `briefing_tools.py`, add `run_agentic_briefing`, add a per-user setting `briefing_mode: "legacy" | "agentic"` (default legacy). Route only the 4am compilation through the new path when the flag is set. Keep slot-injection on the legacy path. Enable the flag on the author's account first, validate output quality over several days, then flip the default for all users. No DB migration required — the setting lives in the existing `settings` table.
**PR 2 — Agentic slot injections + conversation hygiene.** Migrate midday/afternoon check-ins to the same pipeline. Remove the fake `[Midday briefing update]` user-role message; slot updates become plain assistant messages tagged with `metadata.briefing_slot`. Add a chat-message-loader filter that excludes slot-tagged messages from the LLM context on follow-ups (they remain visible in the UI).
**PR 3 — UI polish.** Collapsed tool-call status row in the briefing card ("✓ checked calendar · ✓ looked at tasks · ✓ pulled weather"), expanding to show tool results on click. Tool-result cards (weather, news, task list) rendered inline where useful.
**PR 4 (optional) — Sidecar storage for briefing snapshots.** If the chat-filter approach in PR 2 feels too hacky, migrate briefings out of the conversations table and into a dedicated `briefing_snapshots` table. Frontend renders them as pinned timeline cards separate from chat. Larger refactor; defer until PR 13 prove the approach works.
## Secondary win — tightening the main chat system prompt
The regular chat is already agentic (it uses `stream_chat_with_tools`), but its system prompt does not explicitly require the model to ground factual claims in tool results. The prompt discipline introduced for briefings — *"Never assert facts you didn't learn from a tool. If a tool returns nothing, say so honestly."* — is worth applying to the main chat system prompt in a follow-up PR. The mechanism already works; only the framing needs tightening.
## Out of scope
- The Android Flutter client's failure to render `search_images` tool-result cards. This is a separate rendering gap in the mobile app and does not affect the server-side fix. Tracked separately.
- Re-evaluating the Ollama parallelism setting. The current non-parallel config is correct for this work.
- Replacing the background model for title/summary/observation extraction. That model's role is unrelated to briefings.
+17 -18
View File
@@ -212,8 +212,8 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `services/api_keys.py` | `generate_key()`, `create_api_key()`, `list_api_keys()`, `revoke_api_key()`, `lookup_key()` (SHA-256 hash lookup) |
| `services/llm.py` | `build_context()`, RAG injection, history summarisation, `stream_chat_with_tools()`, URL fetching, SSRF guard |
| `services/generation_task.py` | `run_generation()` — full chat pipeline: intent routing, tool loop, SSE fan-out, push notification; `run_assist_generation()` |
| `services/intent.py` | `classify_intent()` — fast non-streaming LLM call; intent skip heuristic; `_PRIOR_WORK_REFS` fast-path |
| `services/tools.py` | All LLM tool definitions + `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` dispatcher; duplicate guards; `_resolve_project()` 4-step lookup; `search_projects` and `set_rag_scope` tools |
| ~~`services/intent.py`~~ | Removed — intent routing eliminated; the main model handles all tool routing directly |
| `services/tools/` | LLM tool package — decorator-based registry (`_registry.py`), shared helpers (`_helpers.py`), and one module per domain: `notes.py` (create/update/delete/search/list/read), `tasks.py` (list/log_work), `entities.py` (save_person/save_place/lists), `projects.py`, `calendar.py`, `web.py`, `rag.py`, `profile.py`, `rss.py`, `weather.py`, `utility.py` (calculate). `execute_tool()` and `get_tools_for_user()` are the public API. |
| `services/projects.py` | Project CRUD + `generate_project_summary()` (Ollama, fire-and-forget) + `backfill_project_summaries()` (startup) |
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes(orphan_only=False)` (pgvector cosine similarity) |
| `services/generation_buffer.py` | In-memory SSE event buffer; `cancel_event`; 60s cleanup; supports both chat (int keys) and assist (string keys) |
@@ -231,7 +231,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `services/briefing_scheduler.py` | APScheduler `BackgroundScheduler`; slots with catch-up logic; async-safe via `asyncio.create_task` |
| `services/briefing_conversations.py` | Briefing conversation persistence and history queries |
| `services/briefing_profile.py` | Per-user profile note that the assistant updates over time |
| `services/research.py` | SearXNG research pipeline: 5 sub-queries → parallel fetch → synthesis; `search_images` for image category |
| `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes |
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
| `routes/events.py` | `/api/events` — event CRUD routes |
| `services/caldav.py` | Optional CalDAV sync — user-configured external server; syncs to/from internal store via `caldav_uid` FK; `is_caldav_configured()` guards tool activation |
@@ -291,7 +291,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `services/access.py` | Permission resolution for all shared resources |
| `services/llm.py` | `build_context()`, RAG injection, history summarisation |
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
| `services/tools.py` | All LLM tool implementations (`create_note`, `search_notes`, `get_weather`, …) |
| `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry |
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → briefing output |
| `services/briefing_scheduler.py` | APScheduler integration, catch-up logic for missed slots |
@@ -311,26 +311,22 @@ See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
## LLM Pipeline Internals
### Intent Routing
### Tool Routing
Before the main model runs, a lightweight intent classifier (`services/intent.py`) runs concurrently with `build_context()`. It makes a fast non-streaming call using a smaller dedicated model (`OLLAMA_INTENT_MODEL`, default `qwen2.5:7b`) to determine if the message requires a tool call.
**Skip heuristic** — Intent classification is skipped entirely for short messages (≤10 words) with no action/object keywords, saving 400800ms on conversational replies.
**Prior-work fast-path**`_PRIOR_WORK_REFS` regex detects phrases like "research you did", "note you made", "using your research" and returns no-tool immediately, preventing `search_web` from firing when the user references existing notes.
If a tool is detected, the intent's one-sentence `ack` field is streamed as the first chunk (TTFT), the tool executes, then the main model generates a follow-up with the tool result. For chat-only responses the main model streams directly.
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. Extended reasoning (`think=True`) is always on for qwen3-class models: content-based gating was tried but exposed tool-call template fragility on short tool-intent prompts.
### Tool Loop
Multi-round tool loop (max 5 rounds). All implementations in `services/tools.py`; `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
Multi-round tool loop (max 5 rounds). All implementations in `services/tools/` (decorator-based registry); `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
**Duplicate protection on `create_note` / `create_task`:**
**Unified `create_note` tool** — creates both notes and tasks. Setting `status` (e.g. `"todo"`) creates a task; omitting it creates a knowledge note. All task fields (due_date, priority, milestone, parent_task, recurrence_rule) are available on the single tool.
**Duplicate protection on `create_note`:**
1. Exact title match (case-insensitive) → hard block, redirect to `update_note`
2. Fuzzy title match (SequenceMatcher ≥ 82%; punctuation stripped before candidate search) → hard block
3. Semantic content similarity (threshold 0.90, body ≥ 200 chars) → soft block with `requires_confirmation: true`
3. Semantic content similarity (threshold 0.90, body ≥ 80 chars) → soft block with `requires_confirmation: true`
**Project resolution** (`_resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
**Project resolution** (`_helpers.resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
### Context Window and Summarisation
@@ -344,8 +340,11 @@ History summarisation threshold: 30 messages. Keeps 8 recent messages. Summary m
1. Intent model generates 5 focused sub-queries
2. All 5 SearXNG queries run in parallel (200ms stagger to avoid rate limiter)
3. Up to 15 unique URLs fetched in parallel
4. Up to 12 sources passed to synthesis LLM
5. Result saved as a note with `tags=["research"]`
4. LLM generates an outline (28 sections with title + focus)
5. Each section synthesised in parallel from relevant sources
6. Executive summary generated from all section content
7. Index note created with executive summary + links to section notes; section notes linked back via `parent_id`
8. Falls back to single-note synthesis if outline generation fails
SearXNG tip: add the app server IP to `botdetection.ip_lists.pass_ip` in SearXNG `settings.yml` to bypass the rate limiter for trusted backend requests.
+1 -1
View File
@@ -68,7 +68,7 @@ Full conversation history with SSE streaming. Features:
## Web Research
The assistant can search the web (SearXNG) and fetch pages, synthesising findings into notes. A lightweight `search_web` tool answers quick questions inline without saving. Requires `SEARXNG_URL` to be configured.
The assistant can search the web (SearXNG) and fetch pages, synthesising findings into a structured multi-note research output: an index note with an executive summary and links to focused section notes. Each section covers a distinct aspect of the topic with cited sources. Falls back to a single note when outline generation fails. A lightweight `search_web` tool answers quick questions inline without saving. Requires `SEARXNG_URL` to be configured.
## Calendar
@@ -0,0 +1,746 @@
# Knowledge View Task Consolidation — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Consolidate tasks into the Knowledge view as a fifth card type, deprecate `/notes` and `/tasks` list routes, and simplify navigation down to a single Knowledge hub.
**Architecture:** The backend knowledge service (`services/knowledge.py`) stops excluding tasks from queries and adds `type=task` filtering via the `is_task` property (`Note.status IS NOT NULL`). The knowledge route validation gains `"task"` as a valid type. The frontend KnowledgeView gains task card rendering with status/priority/due-date badges. Router redirects replace the deleted list views.
**Tech Stack:** Python/Quart backend (SQLAlchemy), Vue 3 + TypeScript frontend, Pinia stores, Vue Router.
---
## File Map
| Action | Path |
|--------|------|
| Modify | `src/fabledassistant/services/knowledge.py` |
| Modify | `src/fabledassistant/routes/knowledge.py` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `frontend/src/router/index.ts` |
| Modify | `frontend/src/components/AppHeader.vue` |
| Modify | `frontend/src/App.vue` |
| Delete | `frontend/src/views/NotesListView.vue` |
| Delete | `frontend/src/views/TasksListView.vue` |
---
### Task 1: Backend — Include tasks in knowledge queries
**Files:**
- Modify: `src/fabledassistant/services/knowledge.py`
- Modify: `src/fabledassistant/routes/knowledge.py`
**Context:** The knowledge service currently excludes tasks by filtering `Note.status.is_(None)`. Every query function (`query_knowledge`, `query_knowledge_ids`, `_semantic_knowledge_search`, `get_knowledge_tags`, `get_knowledge_counts`) has this exclusion. Adding task support means: (1) removing the task exclusion from the "all types" queries, (2) adding `type=task` as a filter option that maps to `Note.status.isnot(None)`, (3) enriching `_note_to_item` with task-specific fields, (4) updating counts to include tasks.
- [ ] **Step 1: Add `"task"` to `_VALID_TYPES` in the route file**
In `src/fabledassistant/routes/knowledge.py`, change:
```python
_VALID_TYPES = {"note", "person", "place", "list"}
```
to:
```python
_VALID_TYPES = {"note", "person", "place", "list", "task"}
```
- [ ] **Step 2: Update `_note_to_item` to include task fields**
In `src/fabledassistant/services/knowledge.py`, the `_note_to_item` function builds the item dict. After the existing `elif note.entity_type == "list":` block (which ends around line 48), add a task branch. Find:
```python
elif note.entity_type == "list":
# Parse markdown task list syntax into structured items
body = note.body or ""
list_items = []
for line in body.split("\n"):
stripped = line.strip()
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
checked_item = not stripped.startswith("- [ ] ")
list_items.append({"text": stripped[6:], "checked": checked_item})
item["list_items"] = list_items
item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body
return item
```
Replace with:
```python
elif note.entity_type == "list":
# Parse markdown task list syntax into structured items
body = note.body or ""
list_items = []
for line in body.split("\n"):
stripped = line.strip()
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
checked_item = not stripped.startswith("- [ ] ")
list_items.append({"text": stripped[6:], "checked": checked_item})
item["list_items"] = list_items
item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body
# Task fields — included for all items but only meaningful when is_task
if note.is_task:
item["note_type"] = "task"
item["status"] = note.status
item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None
return item
```
This overrides `note_type` to `"task"` for task items (since `entity_type` returns the `note_type` column which is `"note"` for tasks) and adds status/priority/due_date fields.
- [ ] **Step 3: Update `query_knowledge` to include tasks**
In the `query_knowledge` function, the "all types" filter currently excludes tasks. Change the base query and the `else` branch.
Find:
```python
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.status.is_(None)) # exclude tasks
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
# Exclude tasks — already done above; also exclude any legacy nulls
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
```
Replace with:
```python
base = select(Note).where(Note.user_id == user_id)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
# All types including tasks
pass
```
- [ ] **Step 4: Update `_semantic_knowledge_search` to include tasks**
Find:
```python
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 8),
threshold=0.3,
is_task=False,
)
```
Replace with:
```python
is_task_filter = True if note_type == "task" else (False if note_type else None)
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 8),
threshold=0.3,
is_task=is_task_filter,
)
```
Also update the type matching in the filter loop — find:
```python
for _score, note in candidates:
if note_type and note.entity_type != note_type:
continue
```
Replace with:
```python
for _score, note in candidates:
if note_type == "task" and not note.is_task:
continue
elif note_type and note_type != "task" and note.entity_type != note_type:
continue
```
- [ ] **Step 5: Update `query_knowledge_ids` to include tasks**
Find:
```python
base = (
select(Note.id)
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
```
Replace with:
```python
base = select(Note.id).where(Note.user_id == user_id)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
pass
```
- [ ] **Step 6: Update `get_knowledge_tags` to include task tags**
Find:
```python
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
```
Replace with:
```python
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
pass
```
- [ ] **Step 7: Update `get_knowledge_counts` to include tasks**
Find:
```python
async with async_session() as session:
stmt = (
select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.where(Note.note_type.in_(["note", "person", "place", "list"]))
.group_by(Note.note_type)
)
if tags:
for tag in tags:
stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows}
# Ensure all types present even if zero
for t in ("note", "person", "place", "list"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list"))
return counts
```
Replace with:
```python
async with async_session() as session:
# Count non-task types
stmt = (
select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.where(Note.note_type.in_(["note", "person", "place", "list"]))
.group_by(Note.note_type)
)
if tags:
for tag in tags:
stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows}
# Count tasks separately (is_task = status IS NOT NULL)
task_stmt = (
select(func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.isnot(None))
)
if tags:
for tag in tags:
task_stmt = task_stmt.where(Note.tags.contains([tag]))
task_count: int = (await session.execute(task_stmt)).scalar_one()
counts["task"] = task_count
for t in ("note", "person", "place", "list", "task"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
return counts
```
- [ ] **Step 8: Verify backend changes**
```bash
cd /path/to/fabledassistant
make typecheck
make test
```
Expected: no errors.
- [ ] **Step 9: Commit**
```bash
git add src/fabledassistant/services/knowledge.py src/fabledassistant/routes/knowledge.py
git commit -m "feat(knowledge): include tasks in knowledge queries and counts"
```
---
### Task 2: Frontend — Task card rendering in KnowledgeView
**Files:**
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** `KnowledgeView.vue` has a `KnowledgeItem` interface and renders cards in a grid. Each card type has type-specific content (person shows relationship/email, list shows checkboxes, etc.). Task cards need status, priority, and due date display. The `activeType` ref controls filtering; it needs `"task"` as a valid value. The type filter sidebar needs a "Tasks" button. The new-note button interaction changes from split-button to toggle.
- [ ] **Step 1: Add `"task"` to the KnowledgeItem interface and filter type**
In the `<script setup>` section, find the `KnowledgeItem` interface and add task fields:
```ts
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list";
// ... existing fields
```
Change to:
```ts
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list" | "task";
// ... existing fields
```
Also add the task-specific fields at the end of the interface (before the closing `}`):
```ts
// Task-specific
status?: string;
priority?: string;
due_date?: string;
```
Update the `activeType` ref type:
```ts
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
```
Change to:
```ts
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
```
- [ ] **Step 2: Add "Tasks" to the type filter sidebar**
Find the type filter `v-for` in the template:
```html
<button
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
```
Replace with:
```html
<button
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
```
Update the type cast on the click handler. Find:
```html
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
```
Replace with:
```html
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
```
- [ ] **Step 3: Add task card content in the template**
In the card grid, find the note snippet section:
```html
<!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
```
Add a task-specific section above it:
```html
<!-- Task specifics -->
<div v-else-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges">
<span class="status-badge" :class="`status--${item.status}`">
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
</span>
<span
v-if="item.priority && item.priority !== 'none'"
class="priority-badge"
:class="`priority--${item.priority}`"
>{{ item.priority }}</span>
</div>
<span
v-if="item.due_date"
class="task-due"
:class="{ 'task-overdue': isOverdue(item) }"
>{{ formatDate(item.due_date) }}</span>
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
<!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
```
- [ ] **Step 4: Add `isOverdue` helper and update `openItem` for tasks**
In the `<script setup>`, add after the `formatDate` function:
```ts
function isOverdue(item: KnowledgeItem): boolean {
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
return new Date(item.due_date) < new Date(new Date().toDateString());
}
```
Update `openItem` to route tasks to their editor:
```ts
function openItem(item: KnowledgeItem) {
if (item.note_type === 'task') {
router.push(`/tasks/${item.id}`);
} else {
router.push(`/notes/${item.id}`);
}
}
```
- [ ] **Step 5: Update the "New note" button to toggle interaction**
Find the current new-note button markup:
```html
<div class="new-note-wrap">
<button class="btn-new-note" @click="createNew('note')">+ New note</button>
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type"></button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('note')">Note</button>
<button @click="createNew('person')">Person</button>
<button @click="createNew('place')">Place</button>
<button @click="createNew('list')">List</button>
</div>
</div>
```
Replace with:
```html
<div class="new-note-wrap">
<button class="btn-new-note" @click="newNoteMenuOpen ? createNew('note') : (newNoteMenuOpen = true)">+ New note</button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('task')">Task</button>
<button @click="createNew('person')">Person</button>
<button @click="createNew('place')">Place</button>
<button @click="createNew('list')">List</button>
</div>
</div>
```
Add a click-outside handler. In the `<script setup>`, add after the `createNew` function:
```ts
function onClickOutsideNewNote(e: MouseEvent) {
const wrap = document.querySelector('.new-note-wrap');
if (wrap && !wrap.contains(e.target as Node)) {
newNoteMenuOpen.value = false;
}
}
```
In `onMounted`, add:
```ts
document.addEventListener('click', onClickOutsideNewNote);
```
In `onUnmounted`, add:
```ts
document.removeEventListener('click', onClickOutsideNewNote);
```
- [ ] **Step 6: Add task card CSS**
Append to the `<style scoped>` block:
```css
/* ── Task card ──────────────────────────────────────────── */
.k-card--task { border-left: 3px solid #a78bfa; }
.k-card-task {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-badges {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.status-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
.priority-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
.task-due {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.task-overdue {
color: var(--color-overdue);
font-weight: 500;
}
```
Also add the task type badge color. Find:
```css
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
```
Add after it:
```css
.badge--task { background: rgba(167,139,250,0.15); color: #a78bfa; }
```
- [ ] **Step 7: Remove the chevron button CSS**
Find and delete:
```css
.btn-new-chevron {
padding: 7px 9px;
border-radius: 0 8px 8px 0;
border: 1px solid rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
font-size: 0.78rem;
line-height: 1;
transition: background 0.15s, transform 0.15s;
}
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
.btn-new-chevron.open { transform: scaleY(-1); }
```
Update `.btn-new-note` to have full border-radius now that the chevron is gone:
```css
.btn-new-note {
flex: 1;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
text-align: left;
transition: background 0.15s;
}
```
- [ ] **Step 8: Verify TypeScript compiles**
```bash
cd /path/to/fabledassistant/frontend
npx tsc --noEmit
```
Expected: no new errors (pre-existing TipTap errors are fine).
- [ ] **Step 9: Commit**
```bash
git add frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): add task cards with status/priority/due-date display"
```
---
### Task 3: Route redirects, navigation cleanup, dead code removal
**Files:**
- Modify: `frontend/src/router/index.ts`
- Modify: `frontend/src/components/AppHeader.vue`
- Modify: `frontend/src/App.vue`
- Delete: `frontend/src/views/NotesListView.vue`
- Delete: `frontend/src/views/TasksListView.vue`
**Context:** The router currently has `/notes` and `/tasks` pointing to list view components. These become redirects to `/`. The AppHeader has "Tasks" in both desktop and mobile nav. The `g+t` keyboard shortcut navigates to `/tasks` which should change to `/`. The stores (`notes.ts`, `tasks.ts`) are used by other views so they stay.
- [ ] **Step 1: Replace list view routes with redirects**
In `frontend/src/router/index.ts`, find:
```ts
{
path: "/notes",
name: "notes",
component: () => import("@/views/NotesListView.vue"),
},
```
Replace with:
```ts
{
path: "/notes",
redirect: "/",
},
```
Find:
```ts
{
path: "/tasks",
name: "tasks",
component: () => import("@/views/TasksListView.vue"),
},
```
Replace with:
```ts
{
path: "/tasks",
redirect: "/",
},
```
- [ ] **Step 2: Remove "Tasks" from AppHeader navigation**
In `frontend/src/components/AppHeader.vue`, find in the desktop nav-center:
```html
<router-link to="/tasks" class="nav-link">Tasks</router-link>
```
Delete this line.
Find in the mobile dropdown menu:
```html
<router-link to="/tasks" class="nav-link">Tasks</router-link>
```
Delete this line.
- [ ] **Step 3: Update `g+t` keyboard shortcut in App.vue**
In `frontend/src/App.vue`, find in the `onGlobalKeydown` function, inside the `if (pendingPrefix === "g")` block:
```ts
case "t": router.push("/tasks"); break;
```
Replace with:
```ts
case "t": router.push("/"); break;
```
- [ ] **Step 4: Update shortcuts overlay text**
In `frontend/src/App.vue`, find in the shortcuts overlay template:
```html
<kbd class="shortcut-key">t</kbd>
<span class="shortcut-desc">Tasks</span>
```
Replace the description:
```html
<kbd class="shortcut-key">t</kbd>
<span class="shortcut-desc">Knowledge (tasks)</span>
```
- [ ] **Step 5: Delete the deprecated list view files**
```bash
rm frontend/src/views/NotesListView.vue
rm frontend/src/views/TasksListView.vue
```
- [ ] **Step 6: Verify build**
```bash
cd /path/to/fabledassistant/frontend
npx tsc --noEmit
```
Expected: no new errors. The deleted files were only imported via lazy `() => import(...)` in the router, which we already replaced with redirects.
- [ ] **Step 7: Commit**
```bash
git add -A frontend/src/views/NotesListView.vue frontend/src/views/TasksListView.vue \
frontend/src/router/index.ts frontend/src/components/AppHeader.vue frontend/src/App.vue
git commit -m "feat: deprecate /notes and /tasks routes; redirect to Knowledge view"
```
@@ -0,0 +1,786 @@
# Specialized Note Type Editors — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the generic note editor with type-specialized form-first views for Person, Place, and List, and fix tab navigation so focus flows logically from title through content fields, skipping the formatting toolbar.
**Architecture:** `NoteEditorView.vue` gains type-conditional template sections. When `noteType` is `person` or `place`, the main editor area renders a structured form with the TipTap editor in a secondary "Notes" section. When `noteType` is `list`, a dedicated list builder replaces TipTap as the primary interface. `MarkdownToolbar.vue` gets `tabindex="-1"` on buttons. Backend `_note_to_item` gains new person/place fields.
**Tech Stack:** Vue 3 Composition API, TipTap editor, TypeScript, scoped CSS.
---
## File Map
| Action | Path |
|--------|------|
| Modify | `frontend/src/views/NoteEditorView.vue` |
| Modify | `frontend/src/components/MarkdownToolbar.vue` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `src/fabledassistant/services/knowledge.py` |
---
### Task 1: Tab navigation fix — toolbar tabindex + auto-focus
**Files:**
- Modify: `frontend/src/components/MarkdownToolbar.vue`
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** The MarkdownToolbar renders buttons via `v-for` in a single `<button>` element. Adding `tabindex="-1"` removes them from tab order while keeping them clickable. The NoteEditorView already has a `titleRef` — auto-focus on mount needs to call `.focus()` on it. The title placeholder should vary by note type.
- [ ] **Step 1: Add tabindex="-1" to toolbar buttons**
In `frontend/src/components/MarkdownToolbar.vue`, find:
```html
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
@mousedown.prevent="btn.command()"
>
```
Replace with:
```html
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
tabindex="-1"
@mousedown.prevent="btn.command()"
>
```
- [ ] **Step 2: Add auto-focus on mount and type-dependent placeholder**
In `frontend/src/views/NoteEditorView.vue`, find the title input:
```html
<input
ref="titleRef"
v-model="title"
type="text"
placeholder="Title"
class="title-input"
```
Replace with:
```html
<input
ref="titleRef"
v-model="title"
type="text"
:placeholder="titlePlaceholder"
class="title-input"
```
Add the computed property in the `<script setup>` section, after the `isEditing` computed:
```ts
const titlePlaceholder = computed(() => {
switch (noteType.value) {
case 'person': return 'Name';
case 'place': return 'Place name';
case 'list': return 'List title';
default: return 'Title';
}
});
```
- [ ] **Step 3: Auto-focus title on mount**
In the `onMounted` callback, after all the data loading logic (after the draft restore try/catch block), add:
```ts
await nextTick();
titleRef.value?.focus();
```
- [ ] **Step 4: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/MarkdownToolbar.vue frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders"
```
---
### Task 2: Person editor — form-first layout
**Files:**
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** When `noteType === 'person'`, the main content area should render a contact card form instead of the TipTap-first editor. The person metadata fields (currently in the sidebar) move to the main area, and new fields (birthday, organization, address) are added. The TipTap editor becomes a collapsible "Notes" section below. The sidebar keeps project/tags/type/etc but loses the person-specific fields.
- [ ] **Step 1: Add the person form template**
In the template, find the `<!-- ── Main column ──` section. The current structure is:
```html
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<div class="body-tabs-row">
...
</div>
<!-- Streaming/Review/Normal editor templates -->
</div>
```
Wrap the existing main column content in a `v-if="noteType === 'note'"` (and also show it for any type not person/place/list), and add a person form block. Replace the opening of the main column content:
Find the `<div class="note-main"` line and the content inside it up to `</div>` that closes `.note-main`. Wrap all existing content inside:
```html
<div class="note-main">
<!-- ── Person form ──────────────────────────────────────── -->
<template v-if="noteType === 'person'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Relationship</label>
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Birthday</label>
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Email</label>
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Organization</label>
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- ── Generic note editor (existing) ───────────────────── -->
<template v-else-if="noteType === 'note'">
<!-- ... existing TipTap-first editor content stays here ... -->
</template>
</div>
```
IMPORTANT: Do NOT duplicate the existing editor content. Wrap the existing content in `<template v-else-if="noteType === 'note'">` and place the person form as a sibling `<template>` above it. The place and list forms will be added in subsequent tasks.
- [ ] **Step 2: Add `notesExpanded` ref**
In the `<script setup>`, after the `sidebarOpen` ref, add:
```ts
const notesExpanded = ref(false);
```
Also initialize it based on whether the note has body content, in the onMounted data-loading section. After `Object.assign(entityMeta, store.currentNote.metadata || {});` add:
```ts
notesExpanded.value = !!(store.currentNote.body || '').trim();
```
- [ ] **Step 3: Remove person fields from sidebar**
In the sidebar template, find:
```html
<!-- Person metadata -->
<template v-if="noteType === 'person'">
<div class="sb-field">
<label class="sb-label">Relationship</label>
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Email</label>
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
</template>
```
Delete this entire block.
- [ ] **Step 4: Add entity form CSS**
Add to the `<style scoped>` block:
```css
/* ── Entity form (Person / Place) ───────────────────────── */
.entity-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.ef-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
color: var(--color-primary);
}
.ef-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.ef-input:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.ef-input::placeholder {
color: var(--color-text-muted);
}
/* ── Notes section (collapsible TipTap) ─────────────────── */
.notes-section {
margin-top: 16px;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.notes-toggle {
background: none;
border: none;
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 0;
}
.notes-toggle:hover {
color: var(--color-text);
}
.notes-editor-wrap {
margin-top: 8px;
}
```
- [ ] **Step 5: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): person form-first layout with structured fields and collapsible notes"
```
---
### Task 3: Place editor + List builder
**Files:**
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** Place uses the same entity form pattern as Person with different fields. List uses a dedicated checklist builder with Enter-to-add and Backspace-to-delete behavior. Both are additional `<template>` branches in the main column.
- [ ] **Step 1: Add place form template**
In the `note-main` div, after the person `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
```html
<!-- ── Place form ───────────────────────────────────────── -->
<template v-else-if="noteType === 'place'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Hours</label>
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 9am5pm" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Website</label>
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Category</label>
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
```
- [ ] **Step 2: Remove place fields from sidebar**
Find and delete:
```html
<!-- Place metadata -->
<template v-if="noteType === 'place'">
<div class="sb-field">
<label class="sb-label">Address</label>
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Hours</label>
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 95" @input="markDirty" />
</div>
</template>
```
- [ ] **Step 3: Add list item types and state**
In the `<script setup>`, after the `notesExpanded` ref, add:
```ts
// ── List builder ─────────────────────────────────────────────────────────────
interface ListItem {
text: string;
checked: boolean;
}
const listItems = ref<ListItem[]>([]);
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
const lines = bodyText.split('\n');
const items: ListItem[] = [];
const extraLines: string[] = [];
let pastList = false;
for (const line of lines) {
const stripped = line.trimStart();
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
} else if (!pastList && stripped === '' && items.length > 0) {
pastList = true;
} else {
pastList = true;
extraLines.push(line);
}
}
return { items, extra: extraLines.join('\n').trim() };
}
function serializeListToBody(): string {
const listPart = listItems.value
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
const extraPart = body.value.trim();
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
}
function addListItem(afterIndex?: number) {
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
listItems.value.splice(idx, 0, { text: '', checked: false });
markDirty();
nextTick(() => {
listItemRefs.value[idx]?.focus();
});
}
function removeListItem(index: number) {
if (listItems.value.length <= 1) return;
listItems.value.splice(index, 1);
markDirty();
nextTick(() => {
const focusIdx = Math.max(0, index - 1);
listItemRefs.value[focusIdx]?.focus();
});
}
function onListItemKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addListItem(index);
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
e.preventDefault();
removeListItem(index);
}
}
function onListItemInput(index: number) {
markDirty();
}
function toggleListItemCheck(index: number) {
listItems.value[index].checked = !listItems.value[index].checked;
markDirty();
}
```
- [ ] **Step 4: Initialize list items on mount**
In the onMounted data-loading section, after `notesExpanded.value = !!(store.currentNote.body || '').trim();`, add:
```ts
if (noteType.value === 'list') {
const parsed = parseListFromBody(body.value);
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
body.value = parsed.extra;
notesExpanded.value = !!parsed.extra;
}
```
And in the new-note branch (the `else` block after loading), after `noteType.value = qt as NoteType;`, add:
```ts
if (noteType.value === 'list') {
listItems.value = [{ text: '', checked: false }];
}
```
- [ ] **Step 5: Update save to serialize list**
In the `save` function, find where the body is prepared for the API call. Before the `apiPost` or `apiPatch` call that sends the note data, add list serialization. Find the save function's data construction. Add before the API call:
```ts
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
```
Then use `finalBody` instead of `body.value` in the API payload. Find all occurrences of `body: body.value` in the save function and replace with `body: finalBody`.
- [ ] **Step 6: Add list builder template**
In the `note-main` div, after the place `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
```html
<!-- ── List builder ─────────────────────────────────────── -->
<template v-else-if="noteType === 'list'">
<div class="list-builder">
<div
v-for="(item, idx) in listItems"
:key="idx"
class="lb-item"
>
<input
type="checkbox"
:checked="item.checked"
@change="toggleListItemCheck(idx)"
class="lb-check"
tabindex="-1"
/>
<input
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
v-model="item.text"
class="lb-text"
placeholder="List item..."
@keydown="onListItemKeydown($event, idx)"
@input="onListItemInput(idx)"
/>
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">&times;</button>
</div>
<button class="lb-add" @click="addListItem()">+ Add item</button>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
```
- [ ] **Step 7: Add list builder CSS**
Add to the `<style scoped>` block:
```css
/* ── List builder ───────────────────────────────────────── */
.list-builder {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 0;
}
.lb-item {
display: flex;
align-items: center;
gap: 8px;
}
.lb-check {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.lb-text {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.lb-text:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.lb-text::placeholder {
color: var(--color-text-muted);
}
.lb-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s;
}
.lb-item:hover .lb-delete,
.lb-text:focus ~ .lb-delete {
opacity: 1;
}
.lb-delete:hover {
color: var(--color-danger);
}
.lb-add {
background: none;
border: 1px dashed var(--color-border);
border-radius: 8px;
padding: 7px 12px;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
margin-top: 4px;
transition: border-color 0.15s, color 0.15s;
}
.lb-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
```
- [ ] **Step 8: Handle the generic note template wrapper**
Make sure the existing TipTap-first editor content is wrapped in `<template v-else>` (not `v-else-if="noteType === 'note'"`) so it serves as the default for any unrecognized type.
The final structure in `.note-main` should be:
```
<template v-if="noteType === 'person'"> ... </template>
<template v-else-if="noteType === 'place'"> ... </template>
<template v-else-if="noteType === 'list'"> ... </template>
<template v-else> ... existing TipTap editor ... </template>
```
- [ ] **Step 9: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 10: Commit**
```bash
git add frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): place form-first layout and list builder with Enter-to-add"
```
---
### Task 4: Backend — new person/place fields in knowledge cards
**Files:**
- Modify: `src/fabledassistant/services/knowledge.py`
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** The knowledge card display should show the new fields (birthday, organization for person; website, category for place). The backend `_note_to_item` needs to include them. The frontend card rendering needs to display the useful ones.
- [ ] **Step 1: Update `_note_to_item` for person**
In `src/fabledassistant/services/knowledge.py`, find:
```python
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
```
Replace with:
```python
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
item["birthday"] = meta.get("birthday", "")
item["organization"] = meta.get("organization", "")
item["address"] = meta.get("address", "")
```
- [ ] **Step 2: Update `_note_to_item` for place**
Find:
```python
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
```
Replace with:
```python
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
item["website"] = meta.get("website", "")
item["category"] = meta.get("category", "")
```
- [ ] **Step 3: Update KnowledgeItem interface**
In `frontend/src/views/KnowledgeView.vue`, find the `KnowledgeItem` interface and add the new fields:
After `phone?: string;` add:
```ts
birthday?: string;
organization?: string;
```
After `hours?: string;` add:
```ts
website?: string;
category?: string;
```
- [ ] **Step 4: Update person card display**
In the template, find the person card specifics:
```html
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
```
Replace with:
```html
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
```
- [ ] **Step 5: Update place card display**
Find:
```html
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
```
Replace with:
```html
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
```
- [ ] **Step 6: Verify TypeScript compiles and backend syntax**
```bash
cd frontend && npx tsc --noEmit
python -c "import ast; ast.parse(open('src/fabledassistant/services/knowledge.py').read()); print('OK')"
```
- [ ] **Step 7: Commit**
```bash
git add src/fabledassistant/services/knowledge.py frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): show organization/birthday for person cards, category for place cards"
```
@@ -0,0 +1,785 @@
# Modern Fable Visual Identity — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the generic indigo dark-mode palette with a distinctive "Modern Fable" visual identity — deep violet + muted gold, signature card types, pill nav, Fraunces-as-narrator typography, and living micro-details.
**Architecture:** Pure frontend changes across theme CSS, AppHeader, AppLogo, KnowledgeView, ChatPanel, BriefingView, and CalendarView. No backend changes. Each task is independently deployable — palette first, then cards, then nav, then typography, then details.
**Tech Stack:** Vue 3 SFC (scoped CSS), CSS custom properties, Fraunces font (already loaded).
---
## File Map
| Action | Path |
|--------|------|
| Modify | `frontend/src/assets/theme.css` |
| Modify | `frontend/src/components/AppLogo.vue` |
| Modify | `frontend/src/components/AppHeader.vue` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `frontend/src/components/ChatPanel.vue` |
| Modify | `frontend/src/views/BriefingView.vue` |
| Modify | `frontend/src/views/CalendarView.vue` |
| Modify | `frontend/src/App.vue` |
---
### Task 1: Color palette update + logo + scrollbar
**Files:**
- Modify: `frontend/src/assets/theme.css`
- Modify: `frontend/src/components/AppLogo.vue`
- [ ] **Step 1: Update the dark theme palette in theme.css**
In `frontend/src/assets/theme.css`, find the `[data-theme="dark"]` block and replace these values:
```css
[data-theme="dark"] {
--color-bg: #0f0f14;
--color-bg-secondary: #16161f;
--color-bg-card: #1a1a24;
--color-surface: #16161f;
--color-text: #e4e4f0;
--color-text-secondary: #8888a8;
--color-text-muted: #52526a;
--color-border: rgba(124, 58, 237, 0.12);
--color-input-border: rgba(124, 58, 237, 0.22);
--color-primary: #a78bfa;
--color-danger: #f44336;
--color-tag-bg: #2a2a45;
--color-tag-text: #c4b5fd;
--color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4caf50;
--color-toast-error: #f44336;
--color-status-todo: #9aa0a6;
--color-status-todo-bg: #2a2a35;
--color-status-in-progress: #a78bfa;
--color-status-in-progress-bg: #2a2a45;
--color-status-done: #4caf50;
--color-status-done-bg: #1b3a20;
--color-priority-low: #80cbc4;
--color-priority-low-bg: #1a3a38;
--color-priority-medium: #fdd835;
--color-priority-medium-bg: #3a3520;
--color-priority-high: #f44336;
--color-priority-high-bg: #3a1a1a;
--color-wikilink: #c4b5fd;
--color-wikilink-bg: #2a1a45;
--color-overdue: #f44336;
--color-code-bg: #12121a;
--color-code-inline-bg: #1a1a2a;
--color-table-stripe: #14141e;
--color-success: #4ade80;
--color-warning: #facc15;
--color-input-bar-bg: #1a1a24;
--color-input-bar-text: #e4e4f0;
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
--color-overlay: rgba(0, 0, 0, 0.65);
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
--color-bubble-user-text: #b0b0c8;
--color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-accent-warm: #d4a017;
--color-accent-warm-light: #e8c45a;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
}
```
Note: `--color-accent-warm`, `--color-accent-warm-light`, `--color-primary-solid`, and `--color-primary-deep` are new variables.
- [ ] **Step 2: Update the light theme palette**
In the `:root` block, update these values:
```css
:root {
--color-bg: #f5f5fb;
--color-bg-secondary: #ededf5;
--color-bg-card: #ffffff;
--color-surface: #f0f0f8;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-text-muted: #999999;
--color-border: #dddde8;
--color-input-border: #c8c8d8;
--color-primary: #7c3aed;
--color-danger: #d93025;
--color-tag-bg: #ede5ff;
--color-tag-text: #6d28d9;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-toast-success: #34a853;
--color-toast-error: #d93025;
--color-status-todo: #5f6368;
--color-status-todo-bg: #e8eaed;
--color-status-in-progress: #7c3aed;
--color-status-in-progress-bg: #ede5ff;
--color-status-done: #34a853;
--color-status-done-bg: #e6f4ea;
--color-priority-low: #5f9ea0;
--color-priority-low-bg: #e0f2f1;
--color-priority-medium: #f9a825;
--color-priority-medium-bg: #fff8e1;
--color-priority-high: #d93025;
--color-priority-high-bg: #fce8e6;
--color-wikilink: #7b1fa2;
--color-wikilink-bg: #f3e5f5;
--color-overdue: #d93025;
--color-code-bg: #f0f0f8;
--color-code-inline-bg: #eaeaf4;
--color-table-stripe: #f4f4fb;
--color-success: #22c55e;
--color-warning: #eab308;
--color-input-bar-bg: #eaeaf3;
--color-input-bar-text: #1a1a1a;
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
--color-overlay: rgba(0, 0, 0, 0.45);
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
--color-bubble-user-text: #3a3a4a;
--color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
/* Layout */
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
/* New brand variables */
--color-accent-warm: #b8860b;
--color-accent-warm-light: #d4a017;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
}
```
- [ ] **Step 3: Update scrollbar color**
Find:
```css
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.25);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.45);
}
```
Replace with:
```css
::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.25);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.45);
}
```
- [ ] **Step 4: Update focus ring**
Find:
```css
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
```
Replace with:
```css
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
```
- [ ] **Step 5: Update AppLogo gradient**
In `frontend/src/components/AppLogo.vue`, the logo uses `var(--color-primary)` which will automatically pick up the new violet value. No code change needed — the CSS variable update handles it.
However, add a gradient `<defs>` for the book fill to use the deep gradient instead of a flat color. Find the `<style scoped>` block:
```css
.logo-book {
fill: var(--color-primary);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
```
Replace with:
```css
.logo-book {
fill: url(#logo-gradient);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
```
And add a gradient definition inside the `<svg>` element, before the `<!-- Book body -->` comment:
```html
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--color-primary-solid)" />
<stop offset="100%" stop-color="var(--color-primary-deep)" />
</linearGradient>
</defs>
```
- [ ] **Step 6: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/assets/theme.css frontend/src/components/AppLogo.vue
git commit -m "feat(theme): shift palette from indigo to deep violet + muted gold"
```
---
### Task 2: Signature header — pill nav + brand shortening + status pulse
**Files:**
- Modify: `frontend/src/components/AppHeader.vue`
- [ ] **Step 1: Update brand text in header**
Find:
```html
Fabled Assistant
```
Replace with:
```html
<span class="brand-text">Fabled</span>
```
- [ ] **Step 2: Wrap nav-center links in a pill container**
Find the `nav-center` div:
```html
<div class="nav-center">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
```
Replace with:
```html
<div class="nav-center">
<div class="nav-pill-bar">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
</div>
```
- [ ] **Step 3: Replace header and nav CSS**
Replace the entire `<style scoped>` from `.app-header` through `.nav-link.router-link-active` with:
```css
.app-header {
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(124, 58, 237, 0.08);
position: relative;
}
.nav {
padding: 0.6rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
/* Left — brand */
.nav-brand {
display: flex;
align-items: center;
gap: 0.45rem;
text-decoration: none;
flex-shrink: 0;
}
.brand-text {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-optical-sizing: auto;
font-weight: 600;
font-size: 1rem;
letter-spacing: -0.01em;
color: #c4b0f0;
}
/* Center — pill bar */
.nav-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
}
.nav-pill-bar {
display: flex;
align-items: center;
gap: 2px;
background: rgba(124, 58, 237, 0.06);
border-radius: 10px;
padding: 3px;
}
/* Right */
.nav-right {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.nav-link {
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
border-radius: 8px;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
color: var(--color-text-secondary);
background: rgba(124, 58, 237, 0.08);
}
.nav-link.router-link-active {
color: #c4b5fd;
font-weight: 600;
background: rgba(124, 58, 237, 0.2);
box-shadow: 0 0 12px rgba(124, 58, 237, 0.2);
}
```
- [ ] **Step 4: Add status dot pulse animation for loaded state**
Find:
```css
.status-green .status-dot { background: var(--color-success, #2ecc71); }
```
Replace with:
```css
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
```
Find:
```css
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
```
Add after it:
```css
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
}
```
- [ ] **Step 5: Update mobile menu active styling**
Find:
```css
.mobile-menu .nav-link {
padding: 0.5rem 0.75rem;
min-height: 44px;
display: flex;
align-items: center;
}
```
Replace with:
```css
.mobile-menu .nav-link {
padding: 0.5rem 0.75rem;
min-height: 44px;
display: flex;
align-items: center;
border-radius: 8px;
}
.mobile-menu .nav-link.router-link-active {
background: rgba(124, 58, 237, 0.15);
box-shadow: none;
}
```
- [ ] **Step 6: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/components/AppHeader.vue
git commit -m "feat(header): pill nav bar, brand shortening, status pulse, header gradient"
```
---
### Task 3: Card type DNA — gradient bars, corner accents, hover bloom
**Files:**
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** The cards currently have a left accent strip per type. The new design replaces this with top gradient bars (notes, tasks, lists) and corner accents (person, place), plus a unified violet hover bloom.
- [ ] **Step 1: Replace card accent strips with type-specific top bars and borders**
Find in the `<style scoped>`:
```css
/* Type accent strip */
.k-card--person { border-left: 3px solid #10b981; }
.k-card--place { border-left: 3px solid #f59e0b; }
.k-card--list { border-left: 3px solid #38bdf8; }
.k-card--note { border-left: 3px solid #6366f1; }
.k-card--task { border-left: 3px solid #a78bfa; }
```
Replace with:
```css
/* Type-specific card DNA */
.k-card--note { border-color: rgba(124, 58, 237, 0.12); }
.k-card--task { border-color: rgba(212, 160, 23, 0.10); }
.k-card--person { border-color: rgba(16, 185, 129, 0.10); }
.k-card--place { border-color: rgba(245, 158, 11, 0.10); }
.k-card--list { border-color: rgba(56, 189, 248, 0.10); }
/* Top gradient bars */
.k-card--note::before,
.k-card--task::before,
.k-card--list::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 3px;
border-radius: 14px 14px 0 0;
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, #7c3aed, #a78bfa);
}
.k-card--task::before {
width: 50%;
background: linear-gradient(90deg, #d4a017, transparent);
}
.k-card--list::before {
right: 0;
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
}
/* Corner accents for entity types */
.k-card--person::after,
.k-card--place::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 60px;
height: 60px;
border-radius: 0 14px 0 60px;
pointer-events: none;
}
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
```
- [ ] **Step 2: Update card hover to violet bloom**
Find:
```css
.k-card:hover {
border-color: rgba(255,255,255,0.14);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
}
```
Replace with:
```css
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: rgba(124, 58, 237, 0.2);
}
```
- [ ] **Step 3: Add sidebar section dividers**
Find:
```css
.filter-section { margin-bottom: 20px; }
```
Replace with:
```css
.filter-section { margin-bottom: 20px; }
.filter-section + .filter-section::before {
content: '· · ·';
display: block;
text-align: center;
color: rgba(124, 58, 237, 0.3);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
}
```
- [ ] **Step 4: Add scroll fade to card grid**
Find:
```css
.card-grid {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
```
Replace with:
```css
.card-grid {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
```
- [ ] **Step 5: Update task card dates to use amber**
Find in the task card CSS:
```css
.task-due {
font-size: 0.78rem;
color: var(--color-text-muted);
}
```
Replace with:
```css
.task-due {
font-size: 0.78rem;
color: var(--color-accent-warm);
}
```
- [ ] **Step 6: Add Fraunces view title and update sidebar labels**
Find the filter panel label CSS:
```css
.filter-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-muted);
margin-bottom: 6px;
padding: 0 4px;
}
```
Replace with:
```css
.filter-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.72rem;
letter-spacing: 0.02em;
color: var(--color-primary);
margin-bottom: 6px;
padding: 0 4px;
}
```
- [ ] **Step 7: Update empty state with Fraunces narrator voice**
Find:
```html
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
<p>Nothing here yet.</p>
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">Try clearing the filters.</p>
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
</div>
```
Replace with:
```html
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
<p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
</div>
```
Add CSS:
```css
.empty-narrator {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 1rem;
color: var(--color-accent-warm);
opacity: 0.85;
}
```
- [ ] **Step 8: Update card date stamps to amber**
Find:
```css
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
```
Replace with:
```css
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
```
- [ ] **Step 9: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 10: Commit**
```bash
git add frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): card type DNA, violet hover bloom, amber timestamps, narrator empty states"
```
---
### Task 4: ChatPanel + BriefingView + CalendarView — empty states + glow buttons
**Files:**
- Modify: `frontend/src/components/ChatPanel.vue`
- Modify: `frontend/src/views/BriefingView.vue`
- Modify: `frontend/src/views/CalendarView.vue`
- Modify: `frontend/src/App.vue`
- [ ] **Step 1: Update ChatPanel empty state**
In `frontend/src/components/ChatPanel.vue`, find:
```html
>Send a message to start the conversation.</p>
```
Replace with:
```html
>Start a conversation.</p>
```
Find the `.empty-msg` CSS:
```css
.empty-msg {
```
Add these properties (find the existing block and add to it):
```css
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
color: var(--color-accent-warm, #d4a017);
```
- [ ] **Step 2: Add scroll fade to ChatPanel messages**
Find the `.messages-container` CSS in ChatPanel.vue. Add:
```css
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
```
- [ ] **Step 3: Update CalendarView empty state**
In `frontend/src/views/CalendarView.vue`, find:
```html
return ListView(
children: const [
SizedBox(height: 80),
Center(child: Text('No events')),
],
);
```
Wait — that's the Flutter file. In the web CalendarView, there's no dedicated empty state text to update since it's a FullCalendar component. Skip this for the web CalendarView — it doesn't have a custom empty state.
- [ ] **Step 4: Add glow to primary action buttons in App.vue global styles**
In `frontend/src/App.vue`, find the `<style>` block (the global unscoped one). The `btn-send` styles are in `ChatInputBar.vue` which is scoped. Instead, add a global hover glow rule. Find the existing `.app-footer` style and add after it:
No — the glow should be on the specific button components. The `btn-send` in `ChatInputBar.vue` already has a hover shadow. Let me update it there.
In `frontend/src/components/ChatInputBar.vue`, find:
```css
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
```
Replace with:
```css
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
```
- [ ] **Step 5: Update KnowledgeView new-note button glow**
In `frontend/src/views/KnowledgeView.vue`, find:
```css
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
```
Replace with:
```css
.btn-new-note:hover { background: rgba(124, 58, 237, 0.2); box-shadow: 0 0 12px rgba(124, 58, 237, 0.25); }
```
- [ ] **Step 6: Update any remaining hardcoded indigo references in KnowledgeView**
Search for `99, 102, 241` in KnowledgeView.vue and replace with `124, 58, 237`. This covers all the rgba references in filter buttons, borders, today bar chips, etc.
Use find-and-replace across the file: `99, 102, 241``124, 58, 237`
- [ ] **Step 7: Update hardcoded indigo in BriefingView**
Search for `99, 102, 241` in BriefingView.vue and replace with `124, 58, 237`.
Search for `6366f1` in BriefingView.vue and replace with `7c3aed`.
- [ ] **Step 8: Update hardcoded indigo in AppHeader**
Search for `99, 102, 241` in AppHeader.vue and replace with `124, 58, 237` (for any remaining references not covered by Task 2).
- [ ] **Step 9: Update hardcoded indigo in App.vue shortcuts overlay**
Search for `99, 102, 241` in App.vue and replace with `124, 58, 237`.
Search for `6366f1` in App.vue and replace with `7c3aed`.
- [ ] **Step 10: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 11: Commit**
```bash
git add frontend/src/components/ChatPanel.vue frontend/src/components/ChatInputBar.vue \
frontend/src/views/KnowledgeView.vue frontend/src/views/BriefingView.vue \
frontend/src/components/AppHeader.vue frontend/src/App.vue
git commit -m "feat: narrator empty states, scroll fades, glow buttons, violet color sweep"
```
@@ -0,0 +1,144 @@
# Knowledge View Task Consolidation — Design Spec
## Goal
Consolidate tasks into the Knowledge view as a card type, deprecate the standalone `/notes` and `/tasks` list views, and simplify navigation. The Knowledge view becomes the single hub for all content types: notes, tasks, people, places, and lists.
## Architecture
The Knowledge view already renders notes, people, places, and lists as typed cards in a filterable grid with a sidebar. Tasks are added as a fifth card type using the same two-tier pagination system (ID pre-fetch → content batch). The backend knowledge endpoints (`/api/knowledge/ids`, `/api/knowledge/batch`, `/api/knowledge/counts`) are extended to include tasks. No changes to the note/task CRUD API.
## Task Cards
Task cards follow the same layout as other knowledge cards:
- **Left accent strip**: distinct color for tasks (e.g. `#a78bfa` purple to differentiate from note indigo)
- **Type badge**: "Task" in top-right corner
- **Card body**:
- Title (2-line clamp)
- Status badge: `todo` / `in_progress` / `done` / `cancelled` — styled with existing status colors from theme (`--color-status-*`)
- Priority indicator: shown only when priority is not `none` — uses existing priority colors (`--color-priority-*`)
- Due date: shown when set, with overdue styling (`--color-overdue`) when past and status is not `done`/`cancelled`
- **Card footer**: tags (up to 3) + last-modified date — identical to other card types
Clicking a task card navigates to `/tasks/:id/edit` (same as today).
## Filter Sidebar Changes
The type filter section gains a "Tasks" button:
```
Type
──────────
[All] 127
[Notes] 84
[Tasks] 22
[People] 8
[Places] 5
[Lists] 8
```
The filter value for tasks is `type=task`. The backend already stores tasks as notes with `is_task=True`; the knowledge endpoints need to map the `type=task` filter to `is_task=True`.
## New Note Button Interaction
Current: click "New note" to create a note; chevron expands a dropdown with Note/Person/Place/List.
New behavior:
1. **Click "New note"** (when collapsed) → expands to reveal type options: Task, Person, Place, List. The main button label does not change.
2. **Click "New note"** again (when expanded) → navigates to `/notes/new` (generic note).
3. **Click any type option** → navigates to `/notes/new?type=<type>` (for task: `/notes/new?type=task`, which is equivalent to `/tasks/new`).
4. **Click outside** → collapses the dropdown.
This replaces the current chevron split-button pattern with a simpler toggle. The dropdown items are: Task, Person, Place, List (no "Note" item in the dropdown — clicking the button itself creates a note).
## Route Changes
### Redirects
| Old route | New behavior |
|-----------|-------------|
| `/notes` | 302 redirect → `/` (Knowledge view) |
| `/tasks` | 302 redirect → `/` (Knowledge view) |
### Preserved routes (no change)
| Route | Purpose |
|-------|---------|
| `/notes/:id` | Note viewer |
| `/notes/:id/edit` | Note editor |
| `/notes/new` | New note (with optional `?type=` param) |
| `/tasks/:id/edit` | Task editor |
| `/tasks/new` | New task |
### Router implementation
Add redirect entries in the router config:
```ts
{ path: '/notes', redirect: '/' },
{ path: '/tasks', redirect: '/' },
```
### Navigation
Remove from `AppHeader.vue`:
- "Tasks" nav link (`<router-link to="/tasks">`)
- The `/tasks` entry in both desktop nav-center and mobile menu
Remove from `AppHeader.vue` (already done — `/notes` was removed in a prior change, but verify).
### Deleted files
- `frontend/src/views/NotesListView.vue`
- `frontend/src/views/TasksListView.vue`
- `frontend/src/stores/notes.ts` (if only used by NotesListView)
- `frontend/src/stores/tasks.ts` (if only used by TasksListView)
Verify no other components import from these before deleting. The note/task viewer and editor screens import from `api/client.ts` directly, not from the list stores.
## Backend Changes
### `/api/knowledge/ids`
Accept `type=task` as a valid filter. When `type=task`, query `notes` table with `is_task = True`. When `type` is not set (all), include tasks in results alongside notes/people/places/lists.
### `/api/knowledge/batch`
Return task-specific fields for items where `is_task = True`:
- `status`: todo / in_progress / done / cancelled
- `priority`: none / low / normal / high
- `due_date`: ISO date string or null
These are already columns on the `Note` model — just include them in the batch response when the item is a task.
### `/api/knowledge/counts`
Add `task` to the counts response:
```json
{ "note": 84, "task": 22, "person": 8, "place": 5, "list": 8, "total": 127 }
```
### `/api/knowledge/tags`
No change — tasks already have tags on the same `Note` model.
## Keyboard Shortcuts
Remove from `App.vue` `onGlobalKeydown`:
- `case "t": router.push("/tasks/new")` — keep this, it still works
- `case "g"` sequence `case "t": router.push("/tasks")` — change to `router.push("/")` (direct navigation, don't rely on redirect)
Update shortcuts overlay panel text if it references "Tasks list".
## No API Endpoint Changes
All existing REST endpoints remain:
- `GET/POST /api/notes` — notes CRUD
- `GET/POST /api/tasks` — tasks CRUD
- `PATCH /api/notes/:id`, `PATCH /api/tasks/:id`
- `DELETE /api/notes/:id`, `DELETE /api/tasks/:id`
MCP tools (`fable_create_task`, `fable_list_tasks`, etc.) are unaffected.
@@ -0,0 +1,204 @@
# Specialized Note Type Editors — Design Spec
## Goal
Replace the one-size-fits-all note editor with type-specialized views for Person, Place, and List. Each type gets a form-first layout where structured fields are the main content, with a secondary notes area for free text. Fix tab navigation across all note types so focus flows logically from title through fields to body, skipping the formatting toolbar.
## Architecture
The existing `NoteEditorView.vue` remains the single editor component but renders different layouts based on `noteType`. When `noteType` is `person`, `place`, or `list`, the main editor area switches from TipTap-first to form-first. The TipTap editor moves to a secondary "Notes" section below the form fields. The sidebar metadata fields for person/place move into the main content area. The `note_type` field, entity metadata storage, and API contract are unchanged.
## Person Editor
When `noteType === 'person'`, the main content area renders a contact card form instead of the TipTap editor.
### Fields (in order, all in main content area)
| Field | Type | Placeholder | Source |
|-------|------|-------------|--------|
| Name | text input (title) | "Name" | `title` |
| Relationship | text input | "e.g. Friend, Colleague, Family" | `entityMeta.relationship` |
| Birthday | date input | — | `entityMeta.birthday` (new field) |
| Email | email input | "email@example.com" | `entityMeta.email` |
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
| Organization | text input | "Company or organization" | `entityMeta.organization` (new field) |
| Address | text input | "Street, City, State" | `entityMeta.address` (new field for person) |
### Notes section
Below the form fields, a collapsible "Notes" section with the TipTap editor for free-text content. This is where wikilinks, tags, and general context go. The section starts expanded if the note already has body content, collapsed if empty on a new note.
### Layout
```
┌──────────────────────────────────────────┐
│ [← Knowledge] [Save] [Delete] │
│ │
│ Name: [________________________________] │
│ │
│ Relationship: [________________________] │
│ Birthday: [____date picker________] │
│ Email: [________________________] │
│ Phone: [________________________] │
│ Organization: [________________________] │
│ Address: [________________________] │
│ │
│ ▾ Notes │
│ ┌──────────────────────────────────────┐ │
│ │ TipTap editor (markdown body) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [sidebar: project/tags/etc] │
└──────────────────────────────────────────┘
```
### Data migration
Existing person notes may have structured data written as plain text in the body (e.g. "Relationship: daughter Birthday: 2013-12-13"). No automatic migration — the body content stays as-is in the Notes section. Users can move data to the structured fields manually.
## Place Editor
When `noteType === 'place'`, same form-first pattern.
### Fields
| Field | Type | Placeholder | Source |
|-------|------|-------------|--------|
| Name | text input (title) | "Place name" | `title` |
| Address | text input | "Street, City, State" | `entityMeta.address` |
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
| Hours | text input | "e.g. MonFri 9am5pm" | `entityMeta.hours` |
| Website | url input | "https://..." | `entityMeta.website` (new field) |
| Category | text input | "e.g. Restaurant, Office, Doctor" | `entityMeta.category` (new field) |
### Notes section
Same as Person — collapsible TipTap editor below the form.
## List Editor
When `noteType === 'list'`, the main content area renders a checklist builder instead of the TipTap editor.
### List builder
Each list item is a row with:
- Checkbox (toggle checked state)
- Text input (item text, fills available width)
- Delete button (× icon, right side)
Below the items: an "Add item" button.
### Behavior
- **Enter** in any item input: creates a new item below and focuses it
- **Backspace** on an empty item: deletes the item and focuses the previous one
- **Checkbox toggle**: updates the item's checked state
- **Delete button**: removes the item
### Serialization
On save, list items are serialized to markdown checkbox format in the body:
```markdown
- [ ] Buy groceries
- [x] Call dentist
- [ ] Pick up prescription
```
On load, the body is parsed back into structured items (same parser already exists in `knowledge.py` and `KnowledgeView.vue`).
### Notes section
Same collapsible TipTap "Notes" section below the list builder, for additional context that isn't a list item.
### Layout
```
┌──────────────────────────────────────────┐
│ [← Knowledge] [Save] [Delete] │
│ │
│ List title: [____________________________│
│ │
│ [ ] Buy groceries [×] │
│ [x] Call dentist [×] │
│ [ ] Pick up prescription [×] │
│ │
│ [+ Add item] │
│ │
│ ▾ Notes │
│ ┌──────────────────────────────────────┐ │
│ │ TipTap editor (additional context) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [sidebar: project/tags/etc] │
└──────────────────────────────────────────┘
```
## Tab Navigation & Auto-Focus
### All note types
1. **On page load**: focus the title/name input automatically
2. **Tab from title**: skip the formatting toolbar entirely, go to the first content field:
- Note: TipTap editor body
- Person: Relationship field
- Place: Address field
- List: first list item (or "Add item" button if empty)
3. **Tab through fields**: natural order through all form fields
4. **Tab from last form field**: enter the Notes section (TipTap editor)
### Implementation
Set `tabindex="-1"` on all MarkdownToolbar buttons so they are clickable but not in the tab order. The toolbar remains fully functional via mouse/touch — it's just skipped when tabbing.
### Title placeholder by type
| Type | Placeholder |
|------|-------------|
| Note | "Title" |
| Person | "Name" |
| Place | "Place name" |
| List | "List title" |
| Task | "Title" (unchanged, task editor is separate) |
## Sidebar changes
When editing a Person or Place, the type-specific metadata fields (Relationship, Email, Phone, etc.) **move from the sidebar to the main content area**. The sidebar keeps: Project, Milestone, Tags, Suggest Tags, Type selector, Link Suggestions, Writing Assistant, Version History.
The Type selector remains in the sidebar so users can change the type if needed. Changing type switches the layout.
## Backend changes
### New entity metadata fields
The `entity_meta` JSON column on the Note model already stores arbitrary key-value pairs. No schema migration needed — just store the new keys:
- Person: `birthday`, `organization`, `address` (new; `relationship`, `email`, `phone` existing)
- Place: `website`, `category` (new; `address`, `phone`, `hours` existing)
### Knowledge service
Update `_note_to_item` in `services/knowledge.py` to include the new fields in the response for person and place cards:
- Person: add `birthday`, `organization`, `address`
- Place: add `website`, `category`
### Knowledge card display
Update `KnowledgeView.vue` card rendering to show the new fields where useful (e.g. organization on person cards, category on place cards).
## Files changed
| File | Change |
|------|--------|
| `frontend/src/views/NoteEditorView.vue` | Type-conditional layouts, form fields, list builder, tab navigation, auto-focus, title placeholders |
| `frontend/src/views/KnowledgeView.vue` | Card display for new person/place fields |
| `frontend/src/components/MarkdownToolbar.vue` | `tabindex="-1"` on all buttons |
| `src/fabledassistant/services/knowledge.py` | New fields in `_note_to_item` for person/place |
## What does NOT change
- Note model / database schema (entity_meta is already a JSON column)
- API endpoints (same CRUD)
- Task editor (`TaskEditorView.vue`) — separate component, unchanged
- Generic note editing — TipTap-first layout stays for `noteType === 'note'`
- Backend storage format — entity_meta key-value pairs
@@ -0,0 +1,249 @@
# Modern Fable — Visual Identity Design Spec
## Goal
Replace the generic "competent dark-mode Vue app" aesthetic with a distinctive visual identity that is unmistakably Fabled Assistant. The design language evolves from "Illuminated Transcript" to "Modern Fable" — keeping the scholarly DNA but adding personality through color, typography, interaction, and card design that no other app has.
## Color Palette
Shift from indigo (`#6366f1`) to deep violet + muted gold.
### Dark theme
| Role | Old | New | Usage |
|------|-----|-----|-------|
| Primary | `#818cf8` | `#a78bfa` | Text accents, active states, tags, links |
| Primary solid | `#6366f1` | `#7c3aed` | Buttons, gradients, accent strips |
| Primary deep | `#4f46e5` | `#5b21b6` | Gradient endpoints, hover states |
| Accent (warm) | — | `#d4a017` | Due dates, event times, counts, temporal data |
| Accent light | — | `#e8c45a` | Amber hover states |
| Background | `#111113` | `#0f0f14` | Slightly deeper, more dramatic |
| Surface | `#1a1b22` | `#16161f` | Cards, panels |
| Card bg | `#1e1e27` | `#1a1a24` | Card interiors |
| Border | `rgba(99,102,241,0.10)` | `rgba(124,58,237,0.12)` | Violet-tinted borders |
| Text | `#e4e4f0` | `#e4e4f0` | Unchanged |
| Text muted | `#52526a` | `#52526a` | Unchanged |
### Light theme
| Role | Old | New |
|------|-----|-----|
| Primary | `#6366f1` | `#7c3aed` |
| Primary text | `#4f46e5` | `#5b21b6` |
| Accent | — | `#b8860b` (darker gold for light bg) |
| Tag bg | `#ede9fe` | `#ede5ff` |
| Tag text | `#4f46e5` | `#6d28d9` |
### Semantic color rules
- **Violet = structural** — navigation, type badges, status indicators, card accents, CTA buttons
- **Amber/gold = temporal** — due dates, event times, countdown values, "overdue" states, calendar dot, relative timestamps
- This duality is a core brand principle: violet organizes, amber marks time
### Logo update
Update `AppLogo.vue` SVG fill to use the new violet gradient (`#7c3aed``#5b21b6`) instead of the current indigo values.
## Card Design — Type DNA
Each content type gets a distinct visual signature recognizable at a glance without reading the badge.
### Shared card structure
- Background: `var(--color-surface)`
- Border: `1px solid` with type-tinted color at low opacity
- Border-radius: `var(--radius-lg)` (14px)
- Padding: 14px
- Hover: translateY(-2px) + violet shadow bloom (`0 8px 24px rgba(124,58,237,0.15)`)
### Type-specific signatures
**Note** (`note`)
- Top edge: full-width 3px gradient bar (`#7c3aed``#a78bfa`)
- Border tint: `rgba(124,58,237,0.12)`
- Badge color: `#a78bfa`
**Task** (`task`)
- Top edge: half-width 3px gradient bar (`#d4a017` → transparent`), left-aligned — partial bar suggests "in progress"
- Border tint: `rgba(212,160,23,0.10)`
- Badge color: `#d4a017`
- Status badge inline with type badge row
- Due date in amber; overdue in `--color-overdue` (red)
**Person** (`person`)
- Top edge: none
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(16,185,129,0.06)`)
- Border tint: `rgba(16,185,129,0.10)`
- Badge color: `#34d399`
**Place** (`place`)
- Top edge: none
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(245,158,11,0.06)`)
- Border tint: `rgba(245,158,11,0.10)`
- Badge color: `#fbbf24`
**List** (`list`)
- Top edge: full-width 3px gradient bar (`#38bdf8``#7dd3fc`)
- Border tint: `rgba(56,189,248,0.10)`
- Badge color: `#7dd3fc`
- Progress bar beneath checkboxes
### Card hover state
All cards share the same hover treatment:
```css
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124,58,237,0.15), 0 2px 8px rgba(0,0,0,0.3);
border-color: rgba(124,58,237,0.2);
}
```
## Navigation — Signature Header
Replace the flat nav links with a pill-grouped tab bar.
### Structure
```
[Logo + "Fabled"] [ Knowledge | Chat | Briefing | Calendar | News | Projects ] [status · ? · ☀ · ⚙ · user]
```
### Brand in header
- Logo: `AppLogo` SVG at 28px with new violet gradient
- Text: "Fabled" only (not "Fabled Assistant") — Fraunces italic, `#c4b0f0`, 15px
- The full name "Fabled Assistant" appears on the login page and Settings; the header uses the short form
### Tab bar
- Container: `rgba(124,58,237,0.06)` background, `border-radius: 10px`, 3px padding
- Inactive tabs: transparent background, `color: var(--color-text-muted)`
- Active tab: `rgba(124,58,237,0.2)` background, `border-radius: 8px`, `color: #c4b5fd`, soft box-shadow glow `0 0 12px rgba(124,58,237,0.2)`
- Hover (inactive): `rgba(124,58,237,0.08)` background
- Transition: background 0.15s, color 0.15s
### Header background
Subtle gradient: `linear-gradient(180deg, var(--color-surface), var(--color-bg))` with a bottom border of `rgba(124,58,237,0.08)`. Creates depth without being heavy.
### Mobile
On mobile (< 768px), the pill bar collapses into the existing hamburger dropdown menu. The dropdown gets the same violet active styling.
## Typography — Fraunces as Narrator
Fraunces italic becomes the "narrator's voice" of the application — the assistant speaking through the UI. System UI font remains for body text and interactive elements.
### Where Fraunces is used
| Element | Style | Example |
|---------|-------|---------|
| View titles | Fraunces italic, 20-24px, `#c4b0f0` | *Knowledge*, *Chat*, *Briefing* |
| Sidebar section labels | Fraunces italic, 11px, `var(--color-primary)` | *Filter*, *Tags*, *Sort* |
| Empty states | Fraunces italic, 13-15px, `#d4a017` | *"Every story starts with a blank page."* |
| Card headings (h1/h2/h3) | Fraunces, non-italic, 600 weight | Existing behavior, unchanged |
| Briefing greeting | Fraunces italic, 16px | *"Good morning, Bryan"* |
### Where Fraunces is NOT used
- Navigation tab labels (system font, 12-13px)
- Buttons and form labels
- Card body text, snippets, metadata
- Toast messages, error text
### Empty state voice
Each major view gets a distinctive empty state message in Fraunces italic, amber color:
- Knowledge: *"Your story is unwritten. Create your first note to begin."*
- Chat: *"Start a conversation."*
- Calendar: *"No events ahead. A quiet chapter."*
- Briefing (no briefing yet): *"Your daily briefing will appear here each morning."*
## Living Details
Small touches that accumulate into a distinctive feel.
### Glow interactions
- **Buttons**: Primary buttons (`btn-send`, `btn-new-note`, CTAs) get a violet glow on hover: `box-shadow: 0 0 16px rgba(124,58,237,0.35)`
- **Focus ring**: Change from current `color-mix` to a violet glow: `0 0 0 2px rgba(124,58,237,0.4)`
- **Active nav tab**: Soft glow behind the active pill (see Navigation section)
### Amber for temporal data
Consistently use `#d4a017` (dark theme) for all time-related information:
- Due dates on task cards
- Event times on calendar chips
- "3d ago" timestamps on cards
- Overdue badge in the today bar
- Countdown/relative time in briefing
This creates a visual language: when you see amber, it's about *when*.
### Card hover bloom
Cards lift and emit a violet shadow on hover (see Card Design section). The shadow color matches the card's type accent at very low opacity for a subtle differentiation.
### Status dot pulse
The Ollama status indicator in the header gains a CSS pulse animation when the model is loaded:
```css
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74,222,128,0.4); }
50% { box-shadow: 0 0 10px rgba(74,222,128,0.6); }
}
```
Pulse only when status is "loaded" (green). Offline (red) and loading (amber) are static.
### Scroll edge fades
Top and bottom edges of scrollable areas (card grid, chat messages, sidebar tag list) get a gradient mask that fades content into the background. 20px height, using `mask-image: linear-gradient(...)`.
### Sidebar section dividers
Replace flat `border-bottom` between filter sections with a centered ornamental divider:
```css
.filter-section + .filter-section::before {
content: '·';
display: block;
text-align: center;
color: rgba(124,58,237,0.3);
font-size: 1.2rem;
letter-spacing: 0.5em;
padding: 8px 0;
}
```
Three centered dots (` · · · `) in faint violet. Subtle but distinctive.
### Scrollbar
Keep the current thin scrollbar but update the color from indigo to violet:
```css
::-webkit-scrollbar-thumb {
background: rgba(124,58,237,0.25);
}
```
## Files Changed
| File | Change |
|------|--------|
| `frontend/src/assets/theme.css` | Full palette update (both light and dark), scrollbar color |
| `frontend/src/components/AppLogo.vue` | SVG fill gradient update |
| `frontend/src/components/AppHeader.vue` | Pill-grouped nav tabs, brand shortening, header gradient, status pulse |
| `frontend/src/views/KnowledgeView.vue` | Card type DNA (gradient bars, corner accents), hover bloom, section dividers, empty state text, scroll fades, Fraunces view title |
| `frontend/src/components/ChatPanel.vue` | Scroll fade on messages, empty state text |
| `frontend/src/views/CalendarView.vue` | Empty state text, amber event times |
| `frontend/src/views/BriefingView.vue` | Empty state text, Fraunces greeting |
| `frontend/src/views/ChatView.vue` | (uses ChatPanel — inherits changes) |
| `frontend/src/App.vue` | Update any global styles referencing old indigo values |
## What Does NOT Change
- Overall layout structure (sidebar + content + optional graph panel)
- Chat bubble design (user transparent, assistant border-left + shadow)
- TipTap editor styling
- Settings view layout
- Backend — zero changes
- Mobile layout patterns
+125 -3
View File
@@ -27,7 +27,7 @@ The hierarchy is: Project → Milestone → Task/Note.
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
- Status values: `todo`, `in_progress`, `done`, `cancelled`
- Priority values: `low`, `normal`, `high`
- Priority values: `none` (default), `low`, `medium`, `high`
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
@@ -241,7 +241,7 @@ async def fable_create_task(
title: Task title (required).
body: Markdown description / notes for the task.
status: Initial status — one of: todo (default), in_progress, done, cancelled.
priority: One of: low, normal, high. Omit for no priority.
priority: One of: low, medium, high. Omit for no priority (defaults to "none").
project_id: Associate with a project (0 = no project).
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
@@ -280,7 +280,7 @@ async def fable_update_task(
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled.
priority: New priority — one of: low, normal, high.
priority: New priority — one of: none, low, medium, high.
project_id: New project (0 = remove from project). Omit to leave unchanged.
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
"""
@@ -628,6 +628,128 @@ async def fable_remove_rss_feed(feed_id: int) -> dict:
return await briefing.remove_rss_feed(client, feed_id=feed_id)
# ---------------------------------------------------------------------------
# Briefing introspection & control
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_briefings() -> dict:
"""List the user's briefing conversations, newest first.
Each briefing has an associated date and conversation id. Use
``fable_get_briefing_messages`` or ``fable_get_conversation`` with
the id to pull the actual briefing text and tool-call receipts.
Returns a dict with a ``conversations`` list.
"""
async with FableClient() as client:
return await briefing.list_briefing_conversations(client)
@mcp.tool()
async def fable_get_today_briefing() -> dict:
"""Fetch today's briefing conversation, creating it if needed.
Returns the full conversation object including all messages — the
scheduled briefing assistant turns, any tool calls the agentic path
made, and any chat replies the user has sent in the briefing thread.
Use this to inspect what a briefing actually said (and what tool
results grounded it) without having to query by id.
"""
async with FableClient() as client:
return await briefing.get_today_briefing(client)
@mcp.tool()
async def fable_get_briefing_messages(conversation_id: int) -> dict:
"""Fetch all messages for a specific briefing conversation.
Args:
conversation_id: The briefing conversation id from fable_list_briefings.
Returns a dict with a ``messages`` list. Each message includes
role, content, tool_calls (with results), and metadata — the
metadata carries ``briefing_slot`` tags on agentic briefing turns.
"""
async with FableClient() as client:
return await briefing.get_briefing_messages(
client, conversation_id=conversation_id,
)
@mcp.tool()
async def fable_trigger_briefing(slot: str = "compilation") -> dict:
"""Manually run a briefing slot for the current user.
Fires the same data refresh the scheduler does (RSS, weather),
runs the agentic briefing pipeline, and writes the result into
today's briefing conversation. Use this to test prompt changes
without waiting for the next scheduled slot.
Args:
slot: One of ``compilation`` (full morning, default), ``morning``,
``midday``, or ``afternoon``.
Returns a dict with ``conversation_id``, ``message_id``, and ``slot``.
"""
async with FableClient() as client:
return await briefing.trigger_briefing(client, slot=slot)
@mcp.tool()
async def fable_reset_today_briefing(run_compilation: bool = True) -> dict:
"""Wipe today's briefing and (optionally) regenerate from scratch.
Deletes every message in today's briefing conversation — the
conversation row itself is kept so its id stays stable for any
open UI sessions. If ``run_compilation`` is True (the default),
immediately fires the compilation slot afterward so a fresh
briefing lands in place of the deleted content.
Use this when iterating on briefing prompts or tools and you want
to start from a clean slate rather than append another slot update
on top of stale output.
Args:
run_compilation: When True, fire ``POST /api/briefing/trigger``
for the ``compilation`` slot immediately after wiping.
Set False to only wipe without regenerating.
Returns a dict with ``reset`` (the delete result: deleted count +
conversation id) and ``triggered`` (the new message payload, or
null if regeneration was skipped).
"""
async with FableClient() as client:
return await briefing.reset_today_briefing(
client, run_compilation=run_compilation,
)
# ---------------------------------------------------------------------------
# Generic conversation access
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_get_conversation(conversation_id: int) -> dict:
"""Fetch any conversation (chat or briefing) with its full message list.
Returns conversation metadata plus an ordered ``messages`` array.
Each message includes role, content, tool_calls (with results),
context_note_id, and msg_metadata. Tool calls are in the stored
flat format: ``[{"function": name, "arguments": {...}, "result": {...}}]``.
Useful for debugging agentic briefings, inspecting chat history,
or verifying that a tool actually ran with the expected arguments.
"""
async with FableClient() as client:
return await briefing.get_conversation(
client, conversation_id=conversation_id,
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
+67 -1
View File
@@ -1,4 +1,4 @@
"""MCP tools for Fable RSS feed management."""
"""MCP tools for Fable briefings and RSS feed management."""
from __future__ import annotations
from typing import Any
@@ -6,6 +6,8 @@ from typing import Any
from fable_mcp.client import FableClient
# ── RSS feeds ────────────────────────────────────────────────────────────────
async def list_rss_feeds(client: FableClient) -> dict[str, Any]:
"""List the user's RSS feeds."""
return await client.get("/api/briefing/feeds")
@@ -30,3 +32,67 @@ async def add_rss_feed(
async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]:
"""Remove an RSS feed by ID."""
return await client.delete(f"/api/briefing/feeds/{feed_id}")
# ── Briefings ────────────────────────────────────────────────────────────────
async def list_briefing_conversations(client: FableClient) -> dict[str, Any]:
"""List the user's briefing conversations, newest first."""
return await client.get("/api/briefing/conversations")
async def get_today_briefing(client: FableClient) -> dict[str, Any]:
"""Fetch today's briefing conversation with all messages."""
return await client.get("/api/briefing/conversations/today")
async def get_briefing_messages(
client: FableClient,
*,
conversation_id: int,
) -> dict[str, Any]:
"""Fetch messages for a specific briefing conversation."""
return await client.get(f"/api/briefing/conversations/{conversation_id}/messages")
async def trigger_briefing(
client: FableClient,
*,
slot: str = "compilation",
) -> dict[str, Any]:
"""Manually trigger a briefing slot — fires data refresh and runs the pipeline.
Slot is one of: compilation (full morning), morning, midday, afternoon.
"""
return await client.post("/api/briefing/trigger", json={"slot": slot})
async def reset_today_briefing(
client: FableClient,
*,
run_compilation: bool = True,
) -> dict[str, Any]:
"""Delete all messages in today's briefing and optionally regenerate.
Wipes the messages in today's briefing conversation (keeping the
conversation row), then, if ``run_compilation`` is True, fires the
compilation slot so a fresh briefing lands in its place.
"""
reset = await client.post("/api/briefing/reset-today")
if not run_compilation:
return {"reset": reset, "triggered": None}
triggered = await client.post(
"/api/briefing/trigger", json={"slot": "compilation"}
)
return {"reset": reset, "triggered": triggered}
# ── Generic conversations ───────────────────────────────────────────────────
async def get_conversation(
client: FableClient,
*,
conversation_id: int,
) -> dict[str, Any]:
"""Fetch any conversation (chat or briefing) with all messages and tool calls."""
return await client.get(f"/api/chat/conversations/{conversation_id}")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "fable-mcp"
version = "0.2.4"
version = "0.2.6"
description = "MCP server for Fabled Assistant"
requires-python = ">=3.12"
dependencies = [
+393 -1276
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,7 @@
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
"@ricky0123/vad-web": "^0.0.30",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list": "^3.0.0",
@@ -35,6 +36,7 @@
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "~5.9.0",
"vite": "^7.0.0",
"vite-plugin-static-copy": "^4.0.1",
"vue-tsc": "^3.0.0"
}
}
+16 -12
View File
@@ -3,9 +3,9 @@ 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 { useOnnxPreloader } from "@/composables/useOnnxPreloader";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
@@ -13,6 +13,9 @@ import { apiGet, apiPut } from "@/api/client";
useTheme();
const { schedulePreload: scheduleVadPreload } = useOnnxPreloader();
scheduleVadPreload();
const router = useRouter();
const appVersion = ref("dev");
const authStore = useAuthStore();
@@ -26,6 +29,15 @@ function startAppServices() {
settingsStore.checkVoiceStatus();
// Sync browser timezone to the server on every login/page load.
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
// Re-check voice status when the tab becomes visible again (model may
// have finished loading while the user was away).
document.addEventListener("visibilitychange", onVisibilityChange);
}
function onVisibilityChange() {
if (document.visibilityState === "visible" && authStore.isAuthenticated) {
settingsStore.checkVoiceStatus();
}
}
function stopAppServices() {
@@ -82,7 +94,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
switch (e.key) {
case "h": router.push("/"); break;
case "n": router.push("/notes"); break;
case "t": router.push("/tasks"); break;
case "t": router.push("/"); break;
case "p": router.push("/projects"); break;
case "c": router.push("/chat"); break;
case "g": router.push("/graph"); break;
@@ -121,10 +133,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
router.push("/chat");
}
break;
case " ":
e.preventDefault();
document.dispatchEvent(new CustomEvent("voice:ptt-toggle"));
break;
}
}
@@ -155,6 +163,7 @@ watch(
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
document.removeEventListener("visibilitychange", onVisibilityChange);
stopAppServices();
});
</script>
@@ -197,7 +206,7 @@ onUnmounted(() => {
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">t</kbd>
<span class="shortcut-desc">Tasks</span>
<span class="shortcut-desc">Knowledge (tasks)</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
@@ -274,10 +283,6 @@ 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>
@@ -287,7 +292,6 @@ onUnmounted(() => {
<template v-else>
<router-view />
</template>
<VoiceOverlay />
<ToastNotification />
</template>
+5 -2
View File
@@ -426,7 +426,9 @@ 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 }> {
export async function openArticleInChat(
itemId: number,
): Promise<{ conversation_id: number; assistant_message_id?: number; status?: string }> {
return apiPost(`/api/chat/from-article/${itemId}`, {});
}
@@ -649,9 +651,10 @@ 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 }> {
export async function transcribeAudio(blob: Blob, context?: string): Promise<{ transcript: string; duration_ms: number }> {
const form = new FormData()
form.append('audio', blob, 'audio.webm')
if (context) form.append('context', context)
const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
+48 -23
View File
@@ -10,17 +10,17 @@
--color-text-muted: #999999;
--color-border: #dddde8;
--color-input-border: #c8c8d8;
--color-primary: #6366f1;
--color-primary: #7c3aed;
--color-danger: #d93025;
--color-tag-bg: #ede9fe;
--color-tag-text: #4f46e5;
--color-tag-bg: #ede5ff;
--color-tag-text: #6d28d9;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-toast-success: #34a853;
--color-toast-error: #d93025;
--color-status-todo: #5f6368;
--color-status-todo-bg: #e8eaed;
--color-status-in-progress: #6366f1;
--color-status-in-progress-bg: #ede9fe;
--color-status-in-progress: #7c3aed;
--color-status-in-progress-bg: #ede5ff;
--color-status-done: #34a853;
--color-status-done-bg: #e6f4ea;
--color-priority-low: #5f9ea0;
@@ -44,38 +44,52 @@
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
--color-bubble-user-text: #3a3a4a;
--color-bubble-asst-shadow: 0 2px 16px rgba(99, 102, 241, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
/* Layout */
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
--chat-reading-width: min(1200px, 100%);
--chat-context-sidebar-width: 220px;
--color-accent-warm: #b8860b;
--color-accent-warm-light: #d4a017;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
/* Reusable brand patterns */
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 10px rgba(124, 58, 237, 0.35);
--glow-cta-hover: 0 4px 20px rgba(124, 58, 237, 0.55);
--glow-soft: 0 0 16px rgba(124, 58, 237, 0.35);
--color-primary-faint: rgba(124, 58, 237, 0.08);
--color-primary-tint: rgba(124, 58, 237, 0.12);
--color-primary-wash: rgba(124, 58, 237, 0.20);
}
[data-theme="dark"] {
--color-bg: #111113;
--color-bg-secondary: #18181f;
--color-bg-card: #1e1e27;
--color-surface: #1a1b22;
--color-bg: #0f0f14;
--color-bg-secondary: #16161f;
--color-bg-card: #1a1a24;
--color-surface: #16161f;
--color-text: #e4e4f0;
--color-text-secondary: #8888a8;
--color-text-muted: #52526a;
--color-border: rgba(99, 102, 241, 0.10);
--color-input-border: rgba(99, 102, 241, 0.22);
--color-primary: #818cf8;
--color-border: rgba(124, 58, 237, 0.22);
--color-input-border: rgba(124, 58, 237, 0.35);
--color-primary: #a78bfa;
--color-danger: #f44336;
--color-tag-bg: #2a2a45;
--color-tag-text: #a5b4fc;
--color-tag-text: #c4b5fd;
--color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4caf50;
--color-toast-error: #f44336;
--color-status-todo: #9aa0a6;
--color-status-todo-bg: #2a2a35;
--color-status-in-progress: #818cf8;
--color-status-in-progress: #a78bfa;
--color-status-in-progress-bg: #2a2a45;
--color-status-done: #4caf50;
--color-status-done-bg: #1b3a20;
@@ -88,19 +102,30 @@
--color-wikilink: #c4b5fd;
--color-wikilink-bg: #2a1a45;
--color-overdue: #f44336;
--color-code-bg: #16161d;
--color-code-inline-bg: #1e1e2d;
--color-table-stripe: #16161e;
--color-code-bg: #12121a;
--color-code-inline-bg: #1a1a2a;
--color-table-stripe: #14141e;
--color-success: #4ade80;
--color-warning: #facc15;
--color-input-bar-bg: #1e1e27;
--color-input-bar-bg: #1a1a24;
--color-input-bar-text: #e4e4f0;
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
--color-overlay: rgba(0, 0, 0, 0.65);
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
--color-bubble-user-text: #b0b0c8;
--color-bubble-asst-shadow: 0 4px 28px rgba(99, 102, 241, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-accent-warm: #d4a017;
--color-accent-warm-light: #e8c45a;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 12px rgba(124, 58, 237, 0.45);
--glow-cta-hover: 0 4px 24px rgba(124, 58, 237, 0.65);
--glow-soft: 0 0 18px rgba(124, 58, 237, 0.4);
--color-primary-faint: rgba(124, 58, 237, 0.10);
--color-primary-tint: rgba(124, 58, 237, 0.14);
--color-primary-wash: rgba(124, 58, 237, 0.22);
}
*,
@@ -168,11 +193,11 @@ button:not(:disabled):active,
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.25);
background: rgba(124, 58, 237, 0.25);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.45);
background: rgba(124, 58, 237, 0.45);
}
/* Floating inline assist button (teleported to body, cannot be scoped) */
+50 -30
View File
@@ -68,18 +68,19 @@ router.afterEach(() => {
<!-- Left: brand -->
<router-link to="/" class="nav-brand">
<AppLogo :size="34" />
Fabled Assistant
<span class="brand-text">Fabled</span>
</router-link>
<!-- Center: primary navigation (desktop) -->
<div class="nav-center">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<div class="nav-pill-bar">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
</div>
<!-- Right: status + utilities + gear + user -->
@@ -126,7 +127,6 @@ router.afterEach(() => {
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/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>
@@ -152,40 +152,51 @@ router.afterEach(() => {
<style scoped>
.app-header {
background: var(--color-bg-secondary);
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(124, 58, 237, 0.18);
position: relative;
}
.nav {
padding: 0.75rem 1.5rem;
padding: 0.6rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
/* Left */
/* Left — brand */
.nav-brand {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
font-weight: 600;
font-size: 1.15rem;
letter-spacing: -0.01em;
color: var(--color-primary);
gap: 0.45rem;
text-decoration: none;
flex-shrink: 0;
}
.brand-text {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-optical-sizing: auto;
font-weight: 600;
font-size: 1rem;
letter-spacing: -0.01em;
color: #c4b0f0;
}
/* Center — absolutely positioned so it's truly centered regardless of side widths */
/* Center — pill bar */
.nav-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.1rem;
}
.nav-pill-bar {
display: flex;
align-items: center;
gap: 2px;
background: var(--color-primary-faint);
border-radius: 10px;
padding: 3px;
}
/* Right */
@@ -199,20 +210,20 @@ router.afterEach(() => {
.nav-link {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.9rem;
padding: 0.3rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
border-radius: 8px;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
color: var(--color-text);
background: var(--color-primary-tint);
}
.nav-link.router-link-active {
color: var(--color-primary);
color: var(--color-primary-solid);
font-weight: 600;
box-shadow: inset 0 -2px 0 var(--color-primary);
border-radius: 0;
background: rgba(124, 58, 237, 0.25);
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
}
/* Status indicator */
@@ -234,7 +245,7 @@ router.afterEach(() => {
font-weight: 500;
color: var(--color-text-muted);
}
.status-green .status-dot { background: var(--color-success, #2ecc71); }
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; }
.status-orange .status-dot { background: #f97316; }
.status-red .status-dot { background: var(--color-danger, #e74c3c); }
@@ -243,6 +254,10 @@ router.afterEach(() => {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
}
/* Icon buttons (?, theme, gear) */
.btn-icon {
@@ -369,6 +384,11 @@ router.afterEach(() => {
min-height: 44px;
display: flex;
align-items: center;
border-radius: 8px;
}
.mobile-menu .nav-link.router-link-active {
background: var(--color-primary-wash);
box-shadow: none;
}
.mobile-user .btn-logout {
min-height: 36px;
+7 -1
View File
@@ -11,6 +11,12 @@ defineProps<{ size?: number }>();
:height="size ?? 24"
aria-hidden="true"
>
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--color-primary-solid)" />
<stop offset="100%" stop-color="var(--color-primary-deep)" />
</linearGradient>
</defs>
<!-- Book body -->
<path class="logo-book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
<!-- Book spine -->
@@ -37,7 +43,7 @@ defineProps<{ size?: number }>();
<style scoped>
.logo-book {
fill: var(--color-primary);
fill: url(#logo-gradient);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
.logo-spine {
@@ -247,7 +247,7 @@ async function finish() {
}
.wizard-progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
transition: width 0.3s ease;
}
.wizard-step-label {
@@ -375,7 +375,7 @@ async function finish() {
}
.btn-wizard-primary {
padding: 0.5rem 1.25rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: 8px;
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import type { ToolCallRecord } from "@/types/chat";
const props = defineProps<{
toolCalls: ToolCallRecord[];
}>();
const expanded = ref(false);
function shortName(fn: string): string {
return fn
.replace(/^(list|get|search|fetch)_/, "")
.replace(/_/g, " ");
}
interface Pill {
key: string;
name: string;
count: string | null;
state: "ok" | "empty" | "error";
}
const pills = computed<Pill[]>(() =>
props.toolCalls.map((tc, i) => {
const ok = tc.result?.success !== false && tc.status !== "error";
const data = tc.result?.data as Record<string, unknown> | undefined;
let count: string | null = null;
let state: Pill["state"] = ok ? "ok" : "error";
if (ok && data) {
const raw =
(typeof data.count === "number" && data.count) ||
(typeof data.total === "number" && data.total);
if (typeof raw === "number") {
count = String(raw);
if (raw === 0) state = "empty";
}
}
return {
key: `${tc.function}-${i}`,
name: shortName(tc.function),
count,
state,
};
}),
);
const errorCount = computed(() => pills.value.filter((p) => p.state === "error").length);
</script>
<template>
<div class="briefing-status" :class="{ expanded }">
<button class="briefing-status-toggle" @click="expanded = !expanded">
<span class="briefing-status-label">Gathered</span>
<span
v-for="p in pills"
:key="p.key"
class="briefing-status-pill"
:class="`state-${p.state}`"
>
{{ p.name }}<span v-if="p.count != null" class="pill-count">{{ p.count }}</span>
</span>
<span v-if="errorCount" class="briefing-status-errcount">{{ errorCount }} failed</span>
<span class="briefing-status-chevron" :class="{ open: expanded }"></span>
</button>
<div v-if="expanded" class="briefing-status-detail">
<ToolCallCard
v-for="(tc, i) in toolCalls"
:key="i"
:tool-call="tc"
/>
</div>
</div>
</template>
<style scoped>
.briefing-status {
margin: 0.25rem 0 0.5rem;
max-width: 80%;
}
.briefing-status-toggle {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
width: 100%;
padding: 0.35rem 0.55rem;
background: transparent;
border: 1px dashed var(--color-border);
border-radius: 8px;
cursor: pointer;
font: inherit;
color: var(--color-text-muted);
transition: border-color 0.15s, background 0.15s;
}
.briefing-status-toggle:hover {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
}
.briefing-status-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
color: var(--color-primary);
opacity: 0.85;
margin-right: 0.15rem;
}
.briefing-status-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.08rem 0.45rem;
font-size: 0.72rem;
line-height: 1.45;
border-radius: 999px;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
background: var(--color-bg-card);
white-space: nowrap;
}
.briefing-status-pill.state-empty {
opacity: 0.55;
}
.briefing-status-pill.state-error {
border-color: color-mix(in srgb, var(--color-danger, #ef4444) 60%, var(--color-border));
color: var(--color-danger, #ef4444);
}
.pill-count {
font-weight: 600;
font-variant-numeric: tabular-nums;
padding: 0 0.25rem;
border-radius: 4px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-text);
}
.state-error .pill-count {
background: color-mix(in srgb, var(--color-danger, #ef4444) 18%, transparent);
}
.briefing-status-errcount {
font-size: 0.7rem;
color: var(--color-danger, #ef4444);
font-weight: 600;
}
.briefing-status-chevron {
margin-left: auto;
font-size: 0.65rem;
opacity: 0.5;
transition: transform 0.15s;
}
.briefing-status-chevron.open {
transform: rotate(90deg);
}
.briefing-status-detail {
margin-top: 0.4rem;
padding-left: 0.55rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
</style>
+293 -24
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { apiGet, transcribeAudio } from '@/api/client'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useVad } from '@/composables/useVad'
import { useStreamingTts } from '@/composables/useStreamingTts'
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
import { useListenMode } from '@/composables/useListenMode'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useToastStore } from '@/stores/toast'
import type { Note } from '@/types/note'
const props = withDefaults(defineProps<{
@@ -27,6 +32,32 @@ const emit = defineEmits<{
const store = useChatStore()
const settingsStore = useSettingsStore()
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
// ── Streaming TTS (listen mode) ───────────────────────────────────────────────
const listenMode = useListenMode()
const audio = useVoiceAudio()
const speakerPopoverOpen = ref(false)
const tts = useStreamingTts({
streamingContent: computed(() => store.streamingContent),
streaming: computed(() => store.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
function lastAssistantContent(): string {
const msgs = store.currentConversation?.messages ?? []
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
}
function toggleListen() {
listenMode.value = !listenMode.value
if (listenMode.value) {
tts.speak(lastAssistantContent())
} else {
tts.stop()
}
}
// ── Core input ────────────────────────────────────────────────────────────────
const messageInput = ref('')
@@ -107,21 +138,90 @@ function removeAttachedNote() {
attachedNote.value = null
}
// ── PTT ───────────────────────────────────────────────────────────────────────
// ── Voice (click-to-toggle + VAD speech detection) ─────────────────────────
const transcribingVoice = ref(false)
const recorder = useVoiceRecorder()
// Tracks whether VAD detected speech during the current recording session.
// Used to skip the Whisper round-trip when the user manually stops without
// ever speaking — the recording is ambient noise and will transcribe to "".
const vadSawSpeech = ref(false)
async function startPtt() {
if (!voiceEnabled.value || recorder.recording.value) return
await recorder.startRecording()
const vad = useVad({
onSpeechEnd: () => {
// VAD auto-stop: speech was detected and the user has paused. Proceed
// to transcribe the MediaRecorder output.
void stopRecording(false)
},
onNoSpeech: () => {
// User manually stopped without VAD detecting speech. Toast it; the
// stopRecording path below will skip Whisper based on vadSawSpeech.
useToastStore().show('No speech detected', 'warning')
},
})
// Live mic halo. A solid red disc sits behind the mic button and scales
// with `vad.amplitude` (0..1 RMS, already amplified for visibility).
const micGlowStyle = computed(() => {
if (!recorder.recording.value) return { display: 'none' }
const pulse = 0.2 + Math.min(vad.amplitude.value, 1) * 0.8
return {
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
opacity: String(0.45 + pulse * 0.4),
}
})
// vad.speaking flips true on speech-start. Watch it once per session to
// capture that speech was detected at some point, without clearing when
// speech ends. This drives the no-speech guard in stopRecording.
watch(() => vad.speaking.value, (on) => {
if (on) vadSawSpeech.value = true
})
async function toggleVoice() {
if (transcribingVoice.value) return
if (recorder.recording.value) {
await stopRecording(true)
} else {
await startRecording()
}
}
async function stopPtt() {
async function startRecording() {
if (!voiceEnabled.value || recorder.recording.value) return
if (!recorder.isSupported) {
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
return
}
vadSawSpeech.value = false
await recorder.startRecording()
if (recorder.error.value) {
useToastStore().show(recorder.error.value, 'error')
return
}
if (recorder.stream.value) {
await vad.start(recorder.stream.value)
}
}
async function stopRecording(manual: boolean) {
if (manual) {
await vad.stopAndCheck()
} else {
await vad.stop()
}
if (!recorder.recording.value) return
transcribingVoice.value = true
try {
const blob = await recorder.stopRecording()
const { transcript } = await transcribeAudio(blob)
// No-speech guard: user manually stopped without VAD seeing speech.
// Skip Whisper — the toast was already shown by onNoSpeech.
if (manual && !vadSawSpeech.value) {
return
}
// Pass last assistant message as context to reduce STT mishearings
const msgs = store.currentConversation?.messages ?? []
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
const { transcript } = await transcribeAudio(blob, lastAssistant)
if (transcript.trim()) {
messageInput.value = transcript.trim()
await nextTick()
@@ -132,6 +232,15 @@ async function stopPtt() {
finally { transcribingVoice.value = false }
}
// ── Click-outside to dismiss speaker popover ──────────────────────────────────
function onDocumentMousedown(e: MouseEvent) {
if (!speakerPopoverOpen.value) return
const target = e.target as HTMLElement | null
if (!target?.closest('.speaker-wrapper')) speakerPopoverOpen.value = false
}
onMounted(() => document.addEventListener('mousedown', onDocumentMousedown))
onUnmounted(() => document.removeEventListener('mousedown', onDocumentMousedown))
// ── Exposed interface ─────────────────────────────────────────────────────────
function focus() {
inputEl.value?.focus()
@@ -203,26 +312,72 @@ defineExpose({ focus, prefill })
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"
>
<!-- Speaker / listen-mode popover -->
<div v-if="voiceTtsEnabled" class="speaker-wrapper">
<button
class="btn-icon btn-speaker"
:class="{ 'speaker-active': listenMode, 'speaker-busy': tts.speaking.value }"
@click="speakerPopoverOpen = !speakerPopoverOpen"
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
aria-label="Listen and volume settings"
>
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</button>
<div v-if="speakerPopoverOpen" class="speaker-popover">
<button
class="speaker-row speaker-row--toggle"
:class="{ 'speaker-row--active': listenMode }"
@click="toggleListen"
>
<span class="speaker-row-label">{{ listenMode ? 'Listening' : 'Read aloud' }}</span>
<span class="speaker-switch" :class="{ on: listenMode }"></span>
</button>
<div class="speaker-row">
<span class="speaker-row-label">Volume</span>
<input
type="range" min="0" max="1" step="0.05"
:value="audio.volume.value"
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
class="speaker-volume-range"
aria-label="Volume"
/>
<span class="speaker-volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
</div>
<button
v-if="tts.speaking.value"
class="speaker-row speaker-row--stop"
@click="tts.stop()"
>
<svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
<span>Stop playback</span>
</button>
</div>
</div>
<!-- PTT mic (with live amplitude halo behind) -->
<div v-if="voiceEnabled" class="mic-wrapper">
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
<button
class="btn-icon btn-mic"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
@click.prevent="toggleVoice"
:disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
>
<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>
</button>
</div>
<!-- Abort (streaming) or Send -->
<button
@@ -319,9 +474,50 @@ defineExpose({ focus, prefill })
.btn-icon:hover { opacity: 1; }
.btn-icon:disabled { opacity: 0.3; cursor: default; }
.btn-mic.mic-recording { opacity: 1; color: #ef4444; }
.btn-mic.mic-recording {
opacity: 1;
/* White icon sits on top of the red halo so it stays legible at any
pulse size. */
color: #fff;
position: relative;
z-index: 1;
}
.btn-mic.mic-transcribing { opacity: 0.5; }
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
absolutely positioned behind the button, scaled/faded by the
computed `micGlowStyle` so the user gets unmistakable feedback
that their voice is being picked up while the mic icon itself
stays put and readable on top. */
.mic-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mic-glow {
position: absolute;
top: 50%;
left: 50%;
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(239, 68, 68, 0.9) 0%,
rgba(239, 68, 68, 0.6) 55%,
rgba(239, 68, 68, 0) 100%
);
transform: translate(-50%, -50%) scale(1);
transform-origin: center;
pointer-events: none;
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
rather than stepping. */
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
z-index: 0;
}
.note-picker-wrapper { position: relative; }
.note-picker-dropdown {
position: absolute;
@@ -367,7 +563,7 @@ defineExpose({ focus, prefill })
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: 50%;
@@ -376,7 +572,7 @@ defineExpose({ focus, prefill })
flex-shrink: 0;
transition: box-shadow 0.15s;
}
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
.btn-abort-inline {
@@ -394,4 +590,77 @@ defineExpose({ focus, prefill })
flex-shrink: 0;
}
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
/* Speaker / listen-mode popover */
.speaker-wrapper { position: relative; display: inline-flex; flex-shrink: 0; }
.btn-speaker.speaker-active { opacity: 1; color: var(--color-primary); }
.btn-speaker.speaker-busy { opacity: 1; color: #f59e0b; }
.speaker-popover {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
min-width: 220px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
padding: 0.35rem;
z-index: 30;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.speaker-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 0.85rem;
cursor: default;
text-align: left;
width: 100%;
}
.speaker-row--toggle { cursor: pointer; }
.speaker-row--toggle:hover { background: var(--color-bg-secondary); }
.speaker-row--active { color: var(--color-primary); }
.speaker-row--stop {
cursor: pointer;
color: var(--color-text-muted);
justify-content: flex-start;
}
.speaker-row--stop:hover { color: #ef4444; background: var(--color-bg-secondary); }
.speaker-row-label { flex: 1; }
.speaker-volume-range { flex: 1; min-width: 0; }
.speaker-volume-pct {
font-size: 0.75rem;
color: var(--color-text-muted);
min-width: 2.5rem;
text-align: right;
}
.speaker-switch {
position: relative;
width: 28px;
height: 16px;
border-radius: 9999px;
background: var(--color-border);
transition: background 0.15s;
flex-shrink: 0;
}
.speaker-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-bg-card);
transition: transform 0.15s;
}
.speaker-switch.on { background: var(--color-primary); }
.speaker-switch.on::after { transform: translateX(12px); }
</style>
+74 -2
View File
@@ -3,8 +3,16 @@ import { computed } from "vue";
import { renderMarkdown } from "@/utils/markdown";
import { useSettingsStore } from "@/stores/settings";
import ToolCallCard from "@/components/ToolCallCard.vue";
import BriefingToolStatusRow from "@/components/BriefingToolStatusRow.vue";
import type { Message } from "@/types/chat";
const SLOT_LABELS: Record<string, string> = {
compilation: "Full Briefing",
morning: "Morning Update",
midday: "Midday Update",
afternoon: "Afternoon Update",
};
const settingsStore = useSettingsStore();
const props = defineProps<{
@@ -41,6 +49,30 @@ function formatMs(ms: number | null | undefined): string {
return `${(ms / 1000).toFixed(1)}s`;
}
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
const isBriefingIntermediate = computed(
() => props.message.role === "assistant" && metadata.value.briefing_intermediate === true,
);
const briefingSlot = computed(() => {
const slot = metadata.value.briefing_slot;
return typeof slot === "string" ? slot : null;
});
const slotLabel = computed(() => {
const slot = briefingSlot.value;
if (!slot) return null;
return SLOT_LABELS[slot] ?? slot;
});
const isSlotUpdate = computed(
() =>
!isBriefingIntermediate.value &&
briefingSlot.value != null &&
briefingSlot.value !== "compilation",
);
const timingParts = computed((): string[] => {
const t = props.message.timing;
if (!t) return [];
@@ -57,11 +89,16 @@ const timingParts = computed((): string[] => {
</script>
<template>
<div class="chat-message" :class="`role-${message.role}`">
<!-- Briefing intermediate: compact status row, no bubble -->
<div v-if="isBriefingIntermediate" class="chat-message role-assistant briefing-intermediate-row">
<BriefingToolStatusRow :tool-calls="message.tool_calls ?? []" />
</div>
<div v-else class="chat-message" :class="[`role-${message.role}`, { 'slot-update': isSlotUpdate }]">
<div class="message-group">
<div class="message-bubble">
<div class="message-bubble" :class="{ 'bubble-slot': isSlotUpdate }">
<div class="message-header">
<span class="role-label">{{ roleLabel }}</span>
<span v-if="slotLabel" class="slot-badge" :class="{ 'slot-badge-update': isSlotUpdate }">{{ slotLabel }}</span>
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
Save as Note
@@ -281,4 +318,39 @@ details[open] .thinking-summary::before {
margin-right: 0.2rem;
opacity: 0.5;
}
/* ── Briefing intermediate row (no bubble) ──────────────── */
.briefing-intermediate-row {
margin-bottom: 0.5rem;
}
/* ── Slot badge (morning/midday/afternoon/full) ─────────── */
.slot-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
margin-left: 0.5rem;
}
.slot-badge-update {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
color: var(--color-text-muted);
border-color: var(--color-border);
}
/* ── Slot update bubbles: softer treatment than compilation ── */
.slot-update .message-bubble.bubble-slot {
background: transparent;
border-left: 2px solid var(--color-border);
box-shadow: none;
}
.slot-update .message-bubble.bubble-slot .message-content {
font-size: 0.88rem;
color: var(--color-text-secondary);
}
</style>
+370 -304
View File
@@ -1,11 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useStreamingTts } from '@/composables/useStreamingTts'
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
import { useListenMode } from '@/composables/useListenMode'
import { apiGet } from '@/api/client'
import ChatMessage from '@/components/ChatMessage.vue'
import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
import ChatInputBar from '@/components/ChatInputBar.vue'
@@ -34,33 +29,6 @@ const emit = defineEmits<{
}>()
const store = useChatStore()
const settingsStore = useSettingsStore()
// ── Voice / TTS ───────────────────────────────────────────────────────────────
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
const listenMode = useListenMode()
const audio = useVoiceAudio()
const showVolumeSlider = ref(false)
const tts = useStreamingTts({
streamingContent: computed(() => store.streamingContent),
streaming: computed(() => store.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
function lastAssistantContent(): string {
const msgs = store.currentConversation?.messages ?? []
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
}
function toggleListen() {
listenMode.value = !listenMode.value
if (listenMode.value) {
tts.speak(lastAssistantContent())
} else {
tts.stop()
}
}
// ── Scroll ────────────────────────────────────────────────────────────────────
const messagesEl = ref<HTMLElement | null>(null)
@@ -89,47 +57,6 @@ watch(() => store.streamingToolCalls, (calls) => {
}, { deep: true })
watch(() => store.streaming, (s) => { if (!s) _seenCalendarToolIdx.clear() })
// ── RAG scope chip (full, non-briefing, non-workspace) ────────────────────────
const projects = ref<{ id: number; title: string }[]>([])
const scopeDropdownOpen = ref(false)
const scopePulse = ref(false)
const showScopeChip = computed(
() => props.variant === 'full' && !props.briefingMode && !props.projectId
)
const scopeLabel = computed(() => {
const id = store.ragProjectId
if (id === -1) return 'All notes'
if (id === null) return 'Orphan notes'
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`
})
async function loadProjects() {
try {
const data = await apiGet<{ projects: { id: number; title: string }[] }>('/api/projects?status=active')
projects.value = data.projects ?? []
} catch {
projects.value = []
}
}
async function onScopeSelect(value: number | null) {
scopeDropdownOpen.value = false
const convId = store.currentConversation?.id
if (!convId) return
await store.updateRagScope(convId, value)
scopePulse.value = true
setTimeout(() => { scopePulse.value = false }, 600)
}
// Pulse when model-driven scope change fires
watch(() => store.ragProjectId, () => {
if (!showScopeChip.value) return
scopePulse.value = true
setTimeout(() => { scopePulse.value = false }, 600)
})
// ── Note context (full variant — included / suggested / auto-injected notes) ──
const includedNoteIds = ref<Set<number>>(new Set())
const includedNotes = ref<{ id: number; title: string }[]>([])
@@ -190,11 +117,92 @@ function removeIncludedNote(noteId: number) {
}
}
const hasContextSidebar = computed(
() => props.variant === 'full' && !props.briefingMode && !props.projectId &&
(autoInjectedNotes.value.length > 0 || includedNotes.value.length > 0 || suggestedNotes.value.length > 0)
const contextCount = computed(
() => autoInjectedNotes.value.length + suggestedNotes.value.length + includedNotes.value.length
)
const hasContextData = computed(
() => props.variant === 'full' && !props.briefingMode && !props.projectId && contextCount.value > 0
)
// ── Narrow-viewport sidebar toggle ────────────────────────────────────────────
const NARROW_BREAKPOINT = 1200
const isNarrow = ref(typeof window !== 'undefined' && window.innerWidth < NARROW_BREAKPOINT)
const sidebarOpen = ref(false)
function onResize() {
isNarrow.value = window.innerWidth < NARROW_BREAKPOINT
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
function toggleContextSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
const hasContextSidebar = computed(() => hasContextData.value && sidebarOpen.value)
// ── Collapsible sections (per-conversation, localStorage) ─────────────────────
type SectionKey = 'auto' | 'suggested' | 'included'
const collapsedSections = ref<Set<SectionKey>>(new Set())
function storageKey(): string | null {
const id = store.currentConversation?.id
return id ? `fa_chat_ctx_collapsed_${id}` : null
}
function loadCollapsed() {
const key = storageKey()
if (!key) { collapsedSections.value = new Set(); return }
try {
const raw = localStorage.getItem(key)
if (!raw) { collapsedSections.value = new Set(); return }
const arr = JSON.parse(raw) as SectionKey[]
collapsedSections.value = new Set(arr)
} catch {
collapsedSections.value = new Set()
}
}
function saveCollapsed() {
const key = storageKey()
if (!key) return
try {
localStorage.setItem(key, JSON.stringify([...collapsedSections.value]))
} catch { /* ignore */ }
}
function toggleSection(key: SectionKey) {
const next = new Set(collapsedSections.value)
if (next.has(key)) next.delete(key)
else next.add(key)
collapsedSections.value = next
saveCollapsed()
}
watch(() => store.currentConversation?.id, () => loadCollapsed(), { immediate: true })
// ── Empty-state (full variant, fresh/empty conversation) ─────────────────────
const showEmptyState = computed(
() =>
props.variant === 'full'
&& !props.briefingMode
&& !props.projectId
&& !store.streaming
&& (store.currentConversation?.messages.length ?? 0) === 0
)
const recentConversations = computed(() => {
const currentId = store.currentConversation?.id
return store.conversations
.filter((c) => c.id !== currentId && c.message_count > 0)
.slice(0, 5)
})
function startVoiceMode() {
window.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
}
// ── Send ──────────────────────────────────────────────────────────────────────
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null)
@@ -208,7 +216,7 @@ async function onSubmit(payload: { content: string; contextNoteId?: number }) {
payload.content,
payload.contextNoteId,
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
true,
false,
undefined,
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
props.projectId ?? store.ragProjectId,
@@ -249,7 +257,7 @@ async function widgetSend(payload: { content: string; contextNoteId?: number })
widgetConvId.value = conv.id
emit('conversation-started', conv.id)
await store.sendMessage(payload.content, payload.contextNoteId, undefined, true)
await store.sendMessage(payload.content, payload.contextNoteId, undefined, false)
const msgs = store.currentConversation?.messages ?? []
const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === 'assistant')
@@ -271,8 +279,7 @@ async function handleSaveAsNote(messageId: number) {
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
if (showScopeChip.value) await loadProjects()
onMounted(() => {
if (props.autoFocus) nextTick(() => inputBarRef.value?.focus())
})
@@ -289,13 +296,30 @@ async function send(text: string) {
await onSubmit({ content: text })
}
defineExpose({ focus, prefill, send })
// ── Briefing slot separator helper ────────────────────────────────────────────
function hasEarlierBriefingSlot(index: number): boolean {
const msgs = store.currentConversation?.messages ?? []
for (let i = 0; i < index; i++) {
const m = msgs[i]
const meta = m?.metadata as Record<string, unknown> | null | undefined
if (
m?.role === 'assistant' &&
meta?.briefing_slot != null &&
!meta?.briefing_intermediate
) {
return true
}
}
return false
}
defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSidebar, hasContextData })
</script>
<template>
<!-- FULL VARIANT -->
<template v-if="variant === 'full'">
<div class="chat-body" :class="{ 'chat-body--has-sidebar': hasContextSidebar }">
<div class="chat-full">
<!-- Message list -->
<div ref="messagesEl" class="messages-container">
<div class="messages-inner">
@@ -303,13 +327,11 @@ defineExpose({ focus, prefill, send })
v-for="(msg, index) in store.currentConversation?.messages ?? []"
:key="msg.id"
>
<!-- Briefing slot separator: shown before a 2nd+ briefing slot message -->
<!-- Briefing slot separator: before any non-first slot message (skip intermediate tool-call rows) -->
<div
v-if="briefingMode && index > 0 && msg.role === 'assistant' && msg.metadata?.rss_item_ids"
v-if="briefingMode && msg.role === 'assistant' && msg.metadata?.briefing_slot && !msg.metadata?.briefing_intermediate && hasEarlierBriefingSlot(index)"
class="briefing-slot-separator"
>
<span>New Briefing Update</span>
</div>
></div>
<ChatMessage
:message="msg"
@save-as-note="handleSaveAsNote"
@@ -335,124 +357,100 @@ defineExpose({ focus, prefill, send })
</button>
</div>
</template>
<div v-if="showEmptyState" class="chat-empty-state">
<h2 class="empty-greeting">What's on your mind?</h2>
<div v-if="recentConversations.length" class="empty-recent">
<div class="empty-section-label">Jump back in</div>
<router-link
v-for="conv in recentConversations"
:key="conv.id"
:to="`/chat/${conv.id}`"
class="empty-recent-item"
>
<span class="empty-recent-title">{{ conv.title || 'Untitled conversation' }}</span>
<span class="empty-recent-count">{{ conv.message_count }} msg</span>
</router-link>
</div>
<button class="empty-voice-btn" @click="startVoiceMode" title="Start in voice mode">
<span class="voice-icon">🎤</span>
<span>Start in voice mode</span>
</button>
</div>
<p
v-if="!store.currentConversation?.messages.length && !store.streaming"
v-else-if="!store.currentConversation?.messages.length && !store.streaming"
class="empty-msg"
>Send a message to start the conversation.</p>
>Start a conversation.</p>
</div>
</div>
<!-- Context sidebar (full, non-briefing, non-workspace) -->
<aside v-if="hasContextSidebar" class="context-sidebar">
<template v-if="autoInjectedNotes.length">
<div class="context-sidebar-header">Auto-included</div>
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<span
v-if="note.score != null"
class="context-note-score"
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-remove" @click="excludeAutoNote(note.id)">&times;</button>
<button
class="context-sidebar-header"
:class="{ collapsed: collapsedSections.has('auto') }"
@click="toggleSection('auto')"
>
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
<span>Auto-included</span>
<span class="section-count">{{ autoInjectedNotes.length }}</span>
</button>
<div v-show="!collapsedSections.has('auto')">
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
<span class="auto-pill" title="Auto-included by relevance">AUTO</span>
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<span
v-if="note.score != null"
class="context-note-score"
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">&times;</button>
</div>
</div>
</template>
<template v-if="suggestedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<span
v-if="note.score != null"
class="context-note-score"
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
<button
class="context-sidebar-header"
:class="{ collapsed: collapsedSections.has('suggested'), 'context-sidebar-header-gap': autoInjectedNotes.length }"
@click="toggleSection('suggested')"
>
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
<span>Suggested</span>
<span class="section-count">{{ suggestedNotes.length }}</span>
</button>
<div v-show="!collapsedSections.has('suggested')">
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<span
v-if="note.score != null"
class="context-note-score"
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
</div>
</div>
</template>
<template v-if="includedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)">&times;</button>
<button
class="context-sidebar-header"
:class="{ collapsed: collapsedSections.has('included'), 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }"
@click="toggleSection('included')"
>
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
<span>In Context</span>
<span class="section-count">{{ includedNotes.length }}</span>
</button>
<div v-show="!collapsedSections.has('included')">
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">&times;</button>
</div>
</div>
</template>
</aside>
</div>
<!-- Input area (hidden when readOnly) -->
<div v-if="!readOnly" class="input-wrapper">
<!-- Scope chip -->
<div v-if="showScopeChip" class="scope-chip-row">
<div class="scope-chip-wrapper">
<button
class="scope-chip"
:class="{ pulse: scopePulse }"
@click="scopeDropdownOpen = !scopeDropdownOpen"
title="Change RAG scope"
>
<span class="scope-dot"></span> {{ scopeLabel }}
</button>
<div v-if="scopeDropdownOpen" class="scope-dropdown">
<button class="scope-option" :class="{ active: store.ragProjectId === null }" @click="onScopeSelect(null)">Orphan notes only</button>
<button
v-for="p in projects"
:key="p.id"
class="scope-option"
:class="{ active: store.ragProjectId === p.id }"
@click="onScopeSelect(p.id)"
>{{ p.title }}</button>
<button class="scope-option" :class="{ active: store.ragProjectId === -1 }" @click="onScopeSelect(-1)">All notes</button>
</div>
</div>
</div>
<!-- Listen / volume controls -->
<div v-if="voiceTtsEnabled" class="voice-controls">
<div class="volume-wrapper">
<button
class="btn-voice"
:class="{ 'btn-voice--active': listenMode, 'btn-voice--busy': tts.speaking.value }"
@click="toggleListen"
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
aria-label="Toggle listen mode"
>
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</button>
<button
class="btn-voice btn-volume-icon"
@click="showVolumeSlider = !showVolumeSlider"
:title="`Volume: ${Math.round(audio.volume.value * 100)}%`"
aria-label="Volume"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</button>
<div v-if="showVolumeSlider" class="volume-popup">
<input
type="range" min="0" max="1" step="0.05"
:value="audio.volume.value"
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
class="volume-range"
aria-label="Volume"
/>
<span class="volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
</div>
</div>
<button
v-if="tts.speaking.value"
class="btn-voice"
@click="tts.stop()"
title="Stop playback"
>
<svg width="13" height="13" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
</button>
</div>
<!-- Input area (hidden when readOnly) -->
<div v-if="!readOnly" class="input-wrapper">
<!-- Unified input bar -->
<ChatInputBar
ref="inputBarRef"
@@ -461,6 +459,7 @@ defineExpose({ focus, prefill, send })
@submit="onSubmit"
@abort="store.cancelGeneration()"
/>
</div>
</div>
</template>
@@ -513,18 +512,33 @@ defineExpose({ focus, prefill, send })
</template>
<style scoped>
/* ── Full variant layout ── */
.chat-body {
/* ── Full variant layout ──
* Single grid owns the reading column + context sidebar + input bar so
* messages and input bar share one centered reading track while the
* context sidebar spans the full height from header to bottom of view.
* Reading column is always centered (3-column grid: 1fr | content | 1fr).
* The context sidebar overlays the right gutter so it never shifts layout.
*/
.chat-full {
position: relative;
flex: 1;
display: flex;
min-height: 0;
display: grid;
grid-template-columns:
1fr
minmax(0, var(--chat-reading-width))
1fr;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
}
.messages-container {
flex: 1;
grid-column: 2;
grid-row: 1;
overflow-y: auto;
padding: 1rem;
padding: 1rem 1rem 0.75rem;
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
}
.messages-inner {
display: flex;
@@ -535,51 +549,85 @@ defineExpose({ focus, prefill, send })
/* Briefing slot separator */
.briefing-slot-separator {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.25rem 0 0.5rem;
color: var(--color-text-muted, #888);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.7;
}
.briefing-slot-separator::before,
.briefing-slot-separator::after {
content: '';
flex: 1;
height: 1px;
margin: 1.25rem 0 0.75rem;
background: var(--color-border, #333);
opacity: 0.5;
opacity: 0.35;
}
/* Context sidebar */
/* Context sidebar — overlays right gutter, never shifts reading column */
.context-sidebar {
width: 200px;
min-width: 160px;
max-width: 220px;
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: var(--chat-context-sidebar-width);
border-left: 1px solid var(--color-border);
padding: 0.75rem 0.5rem;
overflow-y: auto;
background: var(--color-bg-secondary);
font-size: 0.78rem;
z-index: 2;
}
.context-sidebar-header {
display: flex;
align-items: center;
gap: 0.35rem;
width: 100%;
background: none;
border: none;
padding: 0.15rem 0.1rem;
font-family: inherit;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.35rem;
cursor: pointer;
text-align: left;
}
.context-sidebar-header:hover { color: var(--color-text); }
.context-sidebar-header .chev {
transition: transform 0.15s ease;
}
.context-sidebar-header.collapsed .chev {
transform: rotate(-90deg);
}
.context-sidebar-header .section-count {
margin-left: auto;
color: var(--color-text-muted);
font-weight: 600;
background: var(--color-bg);
padding: 0.05rem 0.3rem;
border-radius: 8px;
}
.context-sidebar-header-gap { margin-top: 0.75rem; }
.context-note {
display: flex;
align-items: center;
gap: 0.2rem;
gap: 0.25rem;
margin-bottom: 0.25rem;
padding: 0.2rem 0.35rem;
border-radius: var(--radius-sm);
}
.context-note-auto {
background: color-mix(in srgb, var(--color-text-muted) 8%, transparent);
opacity: 0.85;
}
.context-note-included {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-left: 2px solid var(--color-primary);
}
.auto-pill {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.05em;
padding: 0.05rem 0.3rem;
border-radius: 4px;
background: var(--color-bg);
color: var(--color-text-muted);
flex-shrink: 0;
}
.context-note-name {
flex: 1;
@@ -613,103 +661,16 @@ defineExpose({ focus, prefill, send })
.context-note-remove:hover { color: #ef4444; }
.context-note-add:hover { color: var(--color-primary); }
/* Input wrapper */
/* Input wrapper — lives in the same reading column as messages */
.input-wrapper {
border-top: 1px solid var(--color-border);
grid-column: 2;
grid-row: 2;
padding: 0.5rem 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
/* Scope chip */
.scope-chip-row { display: flex; }
.scope-chip-wrapper { position: relative; }
.scope-chip {
display: flex;
align-items: center;
gap: 0.3rem;
background: none;
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 0.2rem 0.65rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
@keyframes pulse-chip {
0%, 100% { border-color: var(--color-border); }
50% { border-color: var(--color-primary); color: var(--color-primary); box-shadow: 0 0 6px rgba(99,102,241,0.4); }
}
.scope-dot { font-size: 0.6rem; }
.scope-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 180px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 20;
overflow: hidden;
}
.scope-option {
display: block;
width: 100%;
padding: 0.45rem 0.75rem;
text-align: left;
background: none;
border: none;
font-size: 0.85rem;
color: var(--color-text);
cursor: pointer;
}
.scope-option:hover { background: var(--color-bg-secondary); }
.scope-option.active { color: var(--color-primary); font-weight: 600; }
/* Voice controls */
.voice-controls {
display: flex;
align-items: center;
gap: 0.2rem;
}
.volume-wrapper { position: relative; display: flex; align-items: center; gap: 0.1rem; }
.btn-voice {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
opacity: 0.65;
padding: 0.2rem;
display: flex;
align-items: center;
border-radius: var(--radius-sm);
transition: opacity 0.15s, color 0.15s;
}
.btn-voice:hover { opacity: 1; }
.btn-voice--active { opacity: 1; color: var(--color-primary); }
.btn-voice--busy { opacity: 1; color: #f59e0b; }
.volume-popup {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
z-index: 20;
box-shadow: 0 4px 16px var(--color-shadow);
}
.volume-range { width: 80px; }
.volume-pct { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
/* Queued messages */
.queued-message { opacity: 0.45; }
.queued-bubble {
@@ -746,6 +707,111 @@ defineExpose({ focus, prefill, send })
font-size: 0.9rem;
text-align: center;
padding: 2rem 1rem;
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
color: var(--color-accent-warm, #d4a017);
}
.chat-empty-state {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1.5rem;
padding: 3rem 1rem 2rem;
max-width: 520px;
margin: 0 auto;
}
.empty-greeting {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-weight: 400;
font-size: 1.75rem;
color: var(--color-accent-warm, #d4a017);
text-align: center;
margin: 0;
}
.empty-section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
padding-left: 0.25rem;
}
.empty-recent {
display: flex;
flex-direction: column;
}
.empty-recent-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-radius: var(--radius-md, 10px);
border: 1px solid var(--color-border);
background: var(--color-bg-elevated, rgba(255, 255, 255, 0.02));
color: var(--color-text);
text-decoration: none;
font-size: 0.9rem;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
}
.empty-recent-item + .empty-recent-item {
margin-top: 0.4rem;
}
.empty-recent-item:hover {
border-color: var(--color-primary);
background: rgba(99, 102, 241, 0.05);
transform: translateX(2px);
}
.empty-recent-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.empty-recent-count {
font-size: 0.7rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.empty-voice-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 0.75rem 1.2rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
align-self: center;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.empty-voice-btn:hover {
border-color: var(--color-primary);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
}
.empty-voice-btn .voice-icon {
font-size: 1.05rem;
}
/* ── Widget variant ── */
+105 -16
View File
@@ -64,36 +64,114 @@ function toIso(date: string, time: string): string {
return `${date}T${time}:00${sign}${h}:${min}`;
}
// ── Time helpers ──────────────────────────────────────────────────────────────
/** Round up to next 30-minute boundary */
function nextRoundedTime(): string {
const now = new Date();
let h = now.getHours();
let m = now.getMinutes();
if (m <= 30) { m = 30; }
else { m = 0; h = (h + 1) % 24; }
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** Add hours to a time string (HH:MM), returns HH:MM */
function addHours(time: string, hours: number): string {
const [h, m] = time.split(":").map(Number);
const totalMin = h * 60 + m + hours * 60;
const nh = Math.floor(totalMin / 60) % 24;
const nm = totalMin % 60;
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
}
/** Duration in minutes between two time strings */
function durationMin(start: string, end: string): number {
const [sh, sm] = start.split(":").map(Number);
const [eh, em] = end.split(":").map(Number);
return (eh * 60 + em) - (sh * 60 + sm);
}
/** True if a date+time is in the past */
const isPastEvent = computed(() => {
if (allDay.value || !startDate.value || !startTime.value) return false;
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
return dt.getTime() < Date.now();
});
// Track duration so end moves with start
let _lastDurationMin = 60;
function resetForm() {
if (props.event) {
title.value = props.event.title;
allDay.value = props.event.all_day;
// All-day events: use UTC date string directly to avoid timezone shifting
// (UTC midnight parsed through new Date() becomes the previous day in UTC-X zones)
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : "";
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : "";
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
description.value = props.event.description || "";
location.value = props.event.location || "";
color.value = props.event.color || "";
projectId.value = props.event.project_id;
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
if (_lastDurationMin <= 0) _lastDurationMin = 60;
} else {
title.value = "";
allDay.value = false;
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
const roundedStart = nextRoundedTime();
startDate.value = base;
startTime.value = "09:00";
startTime.value = roundedStart;
endDate.value = base;
endTime.value = "10:00";
endTime.value = addHours(roundedStart, 1);
description.value = "";
location.value = "";
color.value = "";
projectId.value = null;
_lastDurationMin = 60;
}
deleteConfirm.value = false;
}
// When start time changes, move end time to preserve duration
watch(startTime, (newStart) => {
if (allDay.value || !newStart) return;
endTime.value = addHours(newStart, _lastDurationMin / 60);
});
// When start date changes, move end date to match (preserve same-day or multi-day gap)
watch(startDate, (newDate) => {
if (!newDate) return;
endDate.value = newDate;
});
// When end time changes manually, update the tracked duration (but guard against end < start)
watch(endTime, (newEnd) => {
if (allDay.value || !newEnd || !startTime.value) return;
const dur = durationMin(startTime.value, newEnd);
if (dur <= 0) {
// Snap back to start + 1 hour
endTime.value = addHours(startTime.value, 1);
_lastDurationMin = 60;
} else {
_lastDurationMin = dur;
}
});
// All-day toggle: clear/restore times
watch(allDay, (isAllDay) => {
if (isAllDay) {
startTime.value = "";
endTime.value = "";
} else {
const rounded = nextRoundedTime();
startTime.value = rounded;
endTime.value = addHours(rounded, 1);
_lastDurationMin = 60;
}
});
watch(() => props.event, resetForm, { immediate: true });
watch(() => props.initialDate, resetForm);
@@ -113,6 +191,10 @@ async function save() {
toast.show("Start date is required", "error");
return;
}
if (!allDay.value && !startTime.value) {
toast.show("Start time is required", "error");
return;
}
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
const end_dt = endDate.value
@@ -199,18 +281,19 @@ async function doDelete() {
<!-- Start -->
<div class="so-field">
<label class="so-label">Start</label>
<label class="so-label">Start <span class="required">*</span></label>
<div class="dt-row">
<input v-model="startDate" type="date" class="so-input dt-date" />
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" />
<input v-model="startDate" type="date" class="so-input dt-date" required />
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
</div>
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
</div>
<!-- End -->
<div class="so-field">
<label class="so-label">End <span class="so-hint">(optional)</span></label>
<label class="so-label">End</label>
<div class="dt-row">
<input v-model="endDate" type="date" class="so-input dt-date" />
<input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" />
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
</div>
</div>
@@ -232,7 +315,7 @@ async function doDelete() {
<label class="so-label so-label-inline">Color</label>
<div class="color-row">
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
<input v-model="color" class="so-input color-hex" placeholder="#6366f1" />
<input v-model="color" class="so-input color-hex" placeholder="#7c3aed" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"></button>
</div>
</div>
@@ -357,13 +440,19 @@ async function doDelete() {
box-sizing: border-box;
transition: border-color 0.15s;
}
.so-input:focus { outline: none; border-color: var(--color-primary, #6366f1); }
.so-input:focus { outline: none; border-color: var(--color-primary); }
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
.dt-row { display: flex; gap: 0.5rem; }
.dt-date { flex: 1; }
.dt-time { width: 7.5rem; flex-shrink: 0; }
.so-past-hint {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-accent-warm, #d4a017);
font-style: italic;
}
.toggle-btn {
background: var(--color-input-bg, #111113);
@@ -376,8 +465,8 @@ async function doDelete() {
transition: all 0.15s;
}
.toggle-btn.active {
background: var(--color-primary, #6366f1);
border-color: var(--color-primary, #6366f1);
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
@@ -403,7 +492,7 @@ async function doDelete() {
}
.btn-primary {
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: 8px;
@@ -94,10 +94,10 @@ const markers: Record<DiffLine["type"], string> = {
/* ── Streaming ── */
.iap-streaming {
border-color: var(--color-primary, #6366f1);
border-color: var(--color-primary);
}
.iap-streaming .iap-header {
background: color-mix(in srgb, var(--color-primary, #6366f1) 8%, var(--color-bg-secondary));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
}
.iap-pulse {
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
background: var(--color-primary);
flex-shrink: 0;
animation: iap-pulse 1.2s ease-in-out infinite;
}
@@ -172,8 +172,8 @@ const markers: Record<DiffLine["type"], string> = {
font-family: inherit;
}
.iap-btn-toggle:hover {
border-color: var(--color-primary, #6366f1);
color: var(--color-primary, #6366f1);
border-color: var(--color-primary);
color: var(--color-primary);
}
.iap-actions {
@@ -78,6 +78,7 @@ const groups = [
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
tabindex="-1"
@mousedown.prevent="btn.command()"
>
<span class="btn-icon" v-html="ICONS[btn.id]" />
+3 -3
View File
@@ -64,11 +64,11 @@ function goEdit() {
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.note-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
transform: translateY(-2px);
}
@@ -89,7 +89,7 @@ function goEdit() {
}
.note-card.compact:hover {
box-shadow: none;
background: rgba(99, 102, 241, 0.04);
background: rgba(124, 58, 237, 0.04);
transform: none;
}
.note-title-compact {
+1 -1
View File
@@ -337,7 +337,7 @@ onMounted(async () => {
.btn-add-share {
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: 6px;
+1 -1
View File
@@ -40,7 +40,7 @@ defineEmits<{
}
.tag-pill:hover {
color: var(--color-primary);
background: rgba(99, 102, 241, 0.08);
background: var(--color-primary-tint);
}
.dismiss {
background: none;
+2 -2
View File
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.task-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
transform: translateY(-2px);
}
+5 -5
View File
@@ -218,7 +218,7 @@ async function confirmDuplicate() {
if (confirmState.value !== "idle") return;
confirmState.value = "creating";
const args = props.toolCall.arguments as Record<string, unknown>;
const isTask = props.toolCall.function === "create_task";
const isTask = !!(args.status);
const endpoint = isTask ? "/api/tasks" : "/api/notes";
const payload: Record<string, unknown> = {
title: args.title,
@@ -751,7 +751,7 @@ function closeEventSlideOver(changed = false) {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
background: var(--color-primary);
flex-shrink: 0;
}
@@ -773,14 +773,14 @@ function closeEventSlideOver(changed = false) {
}
.tool-event-card.clickable { cursor: pointer; }
.tool-event-card.clickable:hover {
border-color: var(--color-primary, #6366f1);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card, #16161a));
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card, #16161a));
}
.tool-event-card-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
background: var(--color-primary);
flex-shrink: 0;
margin-top: 3px;
}
-568
View File
@@ -1,568 +0,0 @@
<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>
+58 -43
View File
@@ -85,22 +85,30 @@ const fetchedAtLabel = computed(() => {
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
</div>
<div class="weather-forecast" v-if="weather.forecast.length">
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
<span class="forecast-day-name">{{ day.day }}</span>
<span class="forecast-icon">{{ weatherIcon(day.condition) }}</span>
<span class="forecast-condition">{{ day.condition }}</span>
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
<span v-if="day.precip_probability != null && day.precip_probability > 0" class="forecast-precip">
💧 {{ day.precip_probability }}%
</span>
<span v-else-if="day.precip_mm != null && day.precip_mm > 0" class="forecast-precip">
💧 {{ day.precip_mm.toFixed(1) }}mm
</span>
<span v-else class="forecast-precip forecast-precip--dry">💧 </span>
<span class="forecast-wind">💨 {{ day.windspeed_max }} {{ weather.wind_unit ?? 'km/h' }}</span>
</div>
</div>
<table class="weather-forecast" v-if="weather.forecast.length">
<thead>
<tr>
<th></th>
<th></th>
<th>Hi / Lo</th>
<th>💧</th>
<th>💨 {{ weather.wind_unit ?? 'km/h' }}</th>
</tr>
</thead>
<tbody>
<tr v-for="day in weather.forecast" :key="day.day">
<td class="forecast-day-name">{{ day.day }}</td>
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !(day.precip_probability != null && day.precip_probability > 0) && !(day.precip_mm != null && day.precip_mm > 0) }">
<template v-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
<template v-else>&mdash;</template>
</td>
<td class="forecast-wind">{{ day.windspeed_max }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="weather-card weather-unavailable">
Weather data unavailable will retry at next slot.
@@ -115,6 +123,7 @@ const fetchedAtLabel = computed(() => {
padding: 1rem 1.25rem;
margin-bottom: 1rem;
font-size: 0.9rem;
container-type: inline-size;
}
.weather-header {
@@ -142,12 +151,12 @@ const fetchedAtLabel = computed(() => {
}
.weather-icon {
font-size: 2rem;
font-size: clamp(1.5rem, 5cqi, 2.5rem);
line-height: 1;
}
.weather-temp {
font-size: 2rem;
font-size: clamp(1.5rem, 5cqi, 2.5rem);
font-weight: 700;
line-height: 1;
}
@@ -169,20 +178,31 @@ const fetchedAtLabel = computed(() => {
}
.weather-forecast {
display: flex;
gap: 0.75rem;
overflow-x: auto;
padding-top: 0.75rem;
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
border-top: 1px solid var(--color-border);
font-size: 0.8rem;
}
.weather-forecast-day {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
min-width: 4.5rem;
font-size: 0.8rem;
.weather-forecast thead th {
font-size: 0.68rem;
font-weight: 600;
color: var(--color-text-muted);
text-align: right;
padding: 0.5rem 0.4rem 0.25rem;
white-space: nowrap;
}
.weather-forecast thead th:first-child,
.weather-forecast thead th:nth-child(2) {
text-align: left;
}
.weather-forecast tbody td {
padding: 0.3rem 0.4rem;
white-space: nowrap;
vertical-align: middle;
}
.forecast-day-name {
@@ -190,26 +210,16 @@ const fetchedAtLabel = computed(() => {
}
.forecast-icon {
font-size: 1.2rem;
font-size: clamp(0.9rem, 3cqi, 1.3rem);
line-height: 1;
}
.forecast-condition {
font-size: 0.7rem;
color: var(--color-text-muted);
text-align: center;
line-height: 1.2;
word-break: break-word;
}
.forecast-temps {
white-space: nowrap;
text-align: right;
}
.forecast-precip,
.forecast-wind {
font-size: 0.72rem;
white-space: nowrap;
.forecast-precip {
text-align: right;
color: var(--color-text-muted);
}
@@ -217,6 +227,11 @@ const fetchedAtLabel = computed(() => {
opacity: 0.35;
}
.forecast-wind {
text-align: right;
color: var(--color-text-muted);
}
.weather-unavailable {
color: var(--color-text-muted);
font-style: italic;
@@ -0,0 +1,43 @@
import { ref, readonly } from 'vue'
/**
* Idle-preload the Silero VAD ONNX model and its companion assets.
*
* `@ricky0123/vad-web` pulls ~2MB of ONNX model + ~5MB of onnxruntime-web
* WASM the first time `MicVAD.new()` runs. Calling `defaultModelFetcher`
* during page idle warms the browser cache so the first mic click feels
* instant. If the preload fails, VAD simply loads lazily on first click.
*/
const ready = ref(false)
let loading = false
export function useOnnxPreloader() {
async function preload() {
if (ready.value || loading) return
loading = true
try {
const { defaultModelFetcher } = await import('@ricky0123/vad-web')
// Asset lives at site root because vite-plugin-static-copy drops it
// there. Match whatever path MicVAD will use at call time (see useVad).
await defaultModelFetcher('/silero_vad_v5.onnx')
ready.value = true
} catch {
// Non-fatal — MicVAD will load the model itself on first use.
} finally {
loading = false
}
}
function schedulePreload() {
if (ready.value) return
if (typeof window === 'undefined') return
if ('requestIdleCallback' in window) {
(window as Window & { requestIdleCallback: (cb: () => void, opts: { timeout: number }) => void })
.requestIdleCallback(() => void preload(), { timeout: 5000 })
} else {
setTimeout(() => void preload(), 5000)
}
}
return { ready: readonly(ready), schedulePreload }
}
@@ -1,66 +0,0 @@
import { ref, readonly } from 'vue'
export interface SilenceDetectorOptions {
thresholdDb?: number // default -40
silenceDurationMs?: number // default 1500
minRecordingMs?: number // default 500
}
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
const {
thresholdDb = -40,
silenceDurationMs = 1500,
minRecordingMs = 500,
} = options
const amplitude = ref(0)
let audioCtx: AudioContext | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let silenceMs = 0
let startedAt = 0
function start(stream: MediaStream, onSilence: () => void): void {
stop()
audioCtx = new AudioContext()
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 }
}
+157
View File
@@ -0,0 +1,157 @@
import { ref, readonly } from 'vue'
import type { MicVAD } from '@ricky0123/vad-web'
export interface UseVadOptions {
/** Called when VAD detects a real speech-end event after the grace period. */
onSpeechEnd: () => void
/** Called when stopAndCheck() is invoked but no speech was ever detected. */
onNoSpeech: () => void
/** Minimum ms between speech-start and speech-end before the end event fires. */
graceMs?: number
/** Minimum total recording duration before speech-end can fire. */
minRecordingMs?: number
}
/**
* Silero VAD-based speech detector.
*
* Replaces amplitude-based silence detection. Uses the Silero VAD v5 ONNX
* model via `@ricky0123/vad-web`. The package ships the model + audio
* worklet, and depends on `onnxruntime-web`'s WASM. Vite is configured
* (see `vite.config.ts`) to serve these assets from the site root, which
* is why we pass `baseAssetPath: "/"` and `onnxWASMBasePath: "/"`.
*
* `amplitude` is computed separately from a Web Audio API analyser node
* on the same stream — it drives the mic pulse animation and is not part
* of the stop decision. VAD's `onSpeechEnd` is the stop trigger.
*
* If VAD never detects speech during a session, calling `stopAndCheck()`
* fires `onNoSpeech` instead of treating the recording as transcribable.
*/
export function useVad(options: UseVadOptions) {
const {
onSpeechEnd,
onNoSpeech,
graceMs = 1500,
minRecordingMs = 500,
} = options
const speaking = ref(false)
const amplitude = ref(0)
const loaded = ref(false)
let micVad: MicVAD | null = null
let audioCtx: AudioContext | null = null
let analyserInterval: ReturnType<typeof setInterval> | null = null
let speechDetected = false
let speechStartedAt = 0
let recordingStartedAt = 0
let stopped = false
async function start(stream: MediaStream): Promise<void> {
await stop()
stopped = false
speechDetected = false
speechStartedAt = 0
recordingStartedAt = Date.now()
// Visual-only amplitude signal. This does NOT drive the stop decision.
audioCtx = new AudioContext()
await audioCtx.resume().catch(() => {})
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 1024
source.connect(analyser)
const samples = new Float32Array(analyser.fftSize)
analyserInterval = setInterval(() => {
analyser.getFloatTimeDomainData(samples)
let sumSq = 0
for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]
const rms = Math.sqrt(sumSq / samples.length)
amplitude.value = Math.min(rms * 5, 1)
}, 100)
try {
const { MicVAD } = await import('@ricky0123/vad-web')
micVad = await MicVAD.new({
model: 'v5',
baseAssetPath: '/',
onnxWASMBasePath: '/',
// MicVAD manages its own audio graph, so we hand it the same stream
// the MediaRecorder is using. It won't consume or alter the stream.
getStream: async () => stream,
onSpeechStart: () => {
speaking.value = true
if (!speechDetected) {
speechDetected = true
speechStartedAt = Date.now()
}
},
onSpeechEnd: () => {
speaking.value = false
if (stopped) return
const now = Date.now()
const totalElapsed = now - recordingStartedAt
const sinceSpeechStart = speechStartedAt > 0 ? now - speechStartedAt : 0
if (totalElapsed >= minRecordingMs && sinceSpeechStart >= graceMs) {
stopped = true
onSpeechEnd()
}
},
onVADMisfire: () => {
// Speech-like blip that was too short to count. Reset the local flag
// so a true "no speech" session still hits onNoSpeech.
speaking.value = false
},
})
await micVad.start()
loaded.value = true
} catch (e) {
// VAD init failed — the user can still manual-stop. Log for debugging.
console.error('VAD init failed:', e)
}
}
async function stop(): Promise<{ hadSpeech: boolean }> {
const had = speechDetected
stopped = true
speaking.value = false
amplitude.value = 0
if (analyserInterval !== null) {
clearInterval(analyserInterval)
analyserInterval = null
}
if (audioCtx) {
await audioCtx.close().catch(() => {})
audioCtx = null
}
if (micVad) {
try {
await micVad.pause()
await micVad.destroy()
} catch {
// Already destroyed or failed — nothing to clean up further.
}
micVad = null
}
return { hadSpeech: had }
}
async function stopAndCheck(): Promise<void> {
const { hadSpeech } = await stop()
if (!hadSpeech) {
onNoSpeech()
}
}
return {
speaking: readonly(speaking),
amplitude: readonly(amplitude),
loaded: readonly(loaded),
start,
stop,
stopAndCheck,
}
}
+2 -4
View File
@@ -45,8 +45,7 @@ const router = createRouter({
},
{
path: "/notes",
name: "notes",
component: () => import("@/views/NotesListView.vue"),
redirect: "/",
},
{
path: "/notes/new",
@@ -85,8 +84,7 @@ const router = createRouter({
},
{
path: "/tasks",
name: "tasks",
component: () => import("@/views/TasksListView.vue"),
redirect: "/",
},
{
path: "/tasks/new",
+113 -57
View File
@@ -56,10 +56,32 @@ const todayConvId = ref<number | null>(null)
const isToday = computed(() => selectedConvId.value === todayConvId.value)
// Weather panel (left column)
// Weather panel
const weatherData = ref<WeatherData[]>([])
const selectedWeatherIdx = ref(0)
const tempUnit = ref<string>('C')
interface CurrentConditions {
temperature: number | null;
windspeed: number | null;
description: string;
precip_next_3h: number[];
temp_unit: string;
location: string;
}
const currentConditions = ref<CurrentConditions | null>(null)
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
async function loadCurrentConditions() {
try {
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
// Patch the live temperature into the WeatherCard so it stays fresh
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
}
} catch { /* silent — endpoint may not have locations configured */ }
}
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
@@ -90,6 +112,7 @@ async function loadAll() {
getBriefingToday().catch(() => null),
loadWeather(),
loadNews(),
loadCurrentConditions(),
])
conversations.value = convList
if (today) {
@@ -198,11 +221,18 @@ useBackgroundRefresh(
)
let _mounted = true
onUnmounted(() => { _mounted = false })
onUnmounted(() => {
_mounted = false
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
})
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
if (!showWizard.value) {
await loadAll()
// Poll current conditions every 30 minutes
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
}
})
</script>
@@ -232,21 +262,7 @@ onMounted(async () => {
</div>
</header>
<!-- Left column: Weather -->
<div class="briefing-left">
<div class="panel-label">Weather</div>
<template v-if="weatherData.length">
<WeatherCard
v-for="loc in weatherData"
:key="(loc as WeatherData).location"
:weather="loc"
:temp-unit="tempUnit"
/>
</template>
<div v-else class="panel-empty">No weather configured</div>
</div>
<!-- Center column: Chat -->
<!-- Left column: Chat -->
<div class="briefing-center">
<ChatPanel
variant="full"
@@ -257,8 +273,27 @@ onMounted(async () => {
/>
</div>
<!-- Right column: News -->
<!-- Right column: Weather + News -->
<div class="briefing-right">
<!-- Weather section (sticky) -->
<div class="weather-section" v-if="weatherData.length">
<div class="weather-tabs" v-if="weatherData.length > 1">
<button
v-for="(loc, i) in weatherData"
:key="(loc as WeatherData).location"
class="weather-tab"
:class="{ active: selectedWeatherIdx === i }"
@click="selectedWeatherIdx = i"
>{{ (loc as WeatherData).location }}</button>
</div>
<WeatherCard
:weather="weatherData[selectedWeatherIdx]"
:temp-unit="tempUnit"
/>
</div>
<!-- News section (scrollable) -->
<div class="news-section">
<div class="panel-label-row">
<div class="panel-label">Today's News</div>
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
@@ -303,6 +338,7 @@ onMounted(async () => {
>💬</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -318,7 +354,7 @@ onMounted(async () => {
.briefing-shell {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
grid-template-columns: 1fr minmax(320px, 35%);
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
@@ -391,27 +427,10 @@ onMounted(async () => {
}
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.briefing-left :deep(.weather-card) {
margin-bottom: 0;
}
/* ─── Center column (Chat) ───────────────────────────────────────────────── */
/* ─── Left column (Chat) ─────────────────────────────────────────────────── */
.briefing-center {
grid-column: 2;
grid-column: 1;
grid-row: 2;
display: flex;
flex-direction: column;
@@ -423,14 +442,60 @@ onMounted(async () => {
min-height: 0;
}
/* ─── Right column (News) ────────────────────────────────────────────────── */
/* ─── Right column (Weather + News) ──────────────────────────────────────── */
.briefing-right {
grid-column: 3;
grid-column: 2;
grid-row: 2;
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
min-height: 0;
}
.weather-section {
flex-shrink: 0;
padding: 1rem 1rem 0.5rem;
}
.weather-section :deep(.weather-card) {
margin-bottom: 0;
}
.weather-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.weather-tab {
padding: 0.3rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: none;
color: var(--color-text-muted);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.weather-tab:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.weather-tab.active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
font-weight: 600;
}
.news-section {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -457,6 +522,8 @@ onMounted(async () => {
color: var(--color-text-muted);
}
/* ── Current conditions (live) ─────────────────────────── */
.panel-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
@@ -549,29 +616,18 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
@media (max-width: 900px) {
.briefing-shell {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr auto;
}
.briefing-header {
grid-column: 1;
grid-row: 1;
}
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: none;
border-bottom: 1px solid var(--color-border);
max-height: 220px;
grid-template-rows: auto 1fr auto;
}
.briefing-center {
grid-column: 1;
grid-row: 3;
grid-row: 2;
}
.briefing-right {
grid-column: 1;
grid-row: 4;
grid-row: 3;
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 260px;
max-height: 300px;
}
}
</style>
+10 -10
View File
@@ -435,7 +435,7 @@ const upcomingGrouped = computed(() => {
}
.btn-new-event {
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: 8px;
@@ -482,8 +482,8 @@ const upcomingGrouped = computed(() => {
}
:deep(.fc-button:hover),
:deep(.fc-button-active) {
background: var(--color-primary, #6366f1) !important;
border-color: var(--color-primary, #6366f1) !important;
background: var(--color-primary) !important;
border-color: var(--color-primary) !important;
color: #fff !important;
}
:deep(.fc-button:focus) { box-shadow: none !important; }
@@ -494,7 +494,7 @@ const upcomingGrouped = computed(() => {
font-size: 0.82rem;
}
:deep(.fc-daygrid-day.fc-day-today) {
background: rgba(99, 102, 241, 0.08);
background: var(--color-primary-tint);
}
:deep(.fc-event) {
border-radius: 4px;
@@ -505,7 +505,7 @@ const upcomingGrouped = computed(() => {
}
:deep(.fc-event-main) { color: #fff; }
:deep(.fc-event:not([style*="background"])) {
background: var(--color-primary, #6366f1);
background: var(--color-primary);
}
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
@@ -574,8 +574,8 @@ const upcomingGrouped = computed(() => {
background: rgba(255,255,255,0.08);
}
.picker-month.active {
background: rgba(99,102,241,0.22);
color: var(--color-primary, #818cf8);
background: rgba(124,58,237,0.22);
color: var(--color-primary);
font-weight: 700;
}
@@ -634,7 +634,7 @@ const upcomingGrouped = computed(() => {
.upcoming-card-accent {
width: 4px;
flex-shrink: 0;
background: var(--ev-color, #6366f1);
background: var(--ev-color, #7c3aed);
}
.upcoming-card-body {
@@ -691,7 +691,7 @@ const upcomingGrouped = computed(() => {
.popover-accent {
height: 4px;
background: var(--color-primary, #6366f1);
background: var(--color-primary);
}
.popover-content {
@@ -750,7 +750,7 @@ const upcomingGrouped = computed(() => {
}
.popover-btn:hover { opacity: 0.85; }
.popover-btn--edit {
background: var(--color-primary, #6366f1);
background: var(--color-primary);
color: #fff;
}
.popover-btn--close {
+298 -131
View File
@@ -2,6 +2,7 @@
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { apiGet } from "@/api/client";
import ChatPanel from "@/components/ChatPanel.vue";
const route = useRoute();
@@ -10,6 +11,49 @@ const store = useChatStore();
const summarizing = ref(false);
const sidebarOpen = ref(false);
const convSearchQuery = ref("");
const headerKebabOpen = ref(false);
const sidebarKebabOpen = ref(false);
// ── RAG scope chip ────────────────────────────────────────────────────────────
const projects = ref<{ id: number; title: string }[]>([]);
const scopeDropdownOpen = ref(false);
const scopePulse = ref(false);
const scopeLabel = computed(() => {
const id = store.ragProjectId;
if (id === -1) return "All notes";
if (id === null) return "Orphan notes";
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`;
});
async function loadProjects() {
try {
const data = await apiGet<{ projects: { id: number; title: string }[] }>(
"/api/projects?status=active"
);
projects.value = data.projects ?? [];
} catch {
projects.value = [];
}
}
async function onScopeSelect(value: number | null) {
scopeDropdownOpen.value = false;
const id = store.currentConversation?.id;
if (!id) return;
await store.updateRagScope(id, value);
scopePulse.value = true;
setTimeout(() => { scopePulse.value = false; }, 600);
}
watch(
() => store.ragProjectId,
() => {
scopePulse.value = true;
setTimeout(() => { scopePulse.value = false; }, 600);
}
);
let prevConvId: number | null = null;
@@ -46,7 +90,12 @@ const groupedConversations = computed((): ConvGroup[] => {
const buckets: Record<string, typeof store.conversations> = {};
const order: string[] = [];
for (const conv of store.conversations) {
const q = convSearchQuery.value.trim().toLowerCase();
const filtered = q
? store.conversations.filter((c) => (c.title || "").toLowerCase().includes(q))
: store.conversations;
for (const conv of filtered) {
const d = new Date(conv.updated_at);
let key: string;
if (d >= startOfToday) {
@@ -69,6 +118,8 @@ const groupedConversations = computed((): ConvGroup[] => {
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
document.addEventListener("mousedown", onDocumentMousedown);
loadProjects();
await store.fetchConversations();
if (convId.value) {
if (store.currentConversation?.id !== convId.value) {
@@ -187,36 +238,35 @@ async function handleSummarize() {
}
}
// ── Research modal ────────────────────────────────────────────────────────────
const showResearchModal = ref(false);
const researchTopic = ref("");
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
function toggleResearchModal() {
showResearchModal.value = !showResearchModal.value;
if (showResearchModal.value) {
researchTopic.value = "";
nextTick(() => {
const el = document.querySelector(".research-topic-input") as HTMLInputElement;
el?.focus();
});
}
const contextCount = computed(() => chatPanelRef.value?.contextCount ?? 0);
const contextSidebarOpen = computed(() => chatPanelRef.value?.sidebarOpen ?? false);
function toggleContextSidebar() {
chatPanelRef.value?.toggleContextSidebar();
}
function submitResearch() {
const topic = researchTopic.value.trim();
if (!topic) return;
showResearchModal.value = false;
researchTopic.value = "";
// Prefill sends via ChatPanel — user sees it in the input, then it auto-submits
chatPanelRef.value?.send(`Research: ${topic}`);
// ── Kebab dismissal ───────────────────────────────────────────────────────────
function onDocumentMousedown(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (headerKebabOpen.value && !target?.closest(".header-kebab-wrapper")) {
headerKebabOpen.value = false;
}
if (sidebarKebabOpen.value && !target?.closest(".sidebar-kebab-wrapper")) {
sidebarKebabOpen.value = false;
}
if (scopeDropdownOpen.value && !target?.closest(".scope-chip-wrapper")) {
scopeDropdownOpen.value = false;
}
}
// ── Keyboard ──────────────────────────────────────────────────────────────────
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
if (showResearchModal.value) {
showResearchModal.value = false;
if (headerKebabOpen.value || sidebarKebabOpen.value || scopeDropdownOpen.value) {
headerKebabOpen.value = false;
sidebarKebabOpen.value = false;
scopeDropdownOpen.value = false;
return;
}
if (sidebarOpen.value) {
@@ -228,6 +278,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
document.removeEventListener("mousedown", onDocumentMousedown);
if (prevConvId) {
const conv = store.conversations.find((c) => c.id === prevConvId);
if (conv && conv.message_count === 0) {
@@ -246,13 +297,35 @@ onUnmounted(() => {
></div>
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
<div class="sidebar-top-bar">
<button class="btn-new-conv" @click="newConversation">+ New Chat</button>
<button
class="btn-select-mode"
:class="{ active: selectMode }"
@click="toggleSelectMode"
title="Select conversations"
>Select</button>
<button class="btn-new-conv btn-new-conv--full" @click="newConversation">+ New Chat</button>
<div class="sidebar-search-row">
<input
v-model="convSearchQuery"
class="conv-search-input"
type="search"
placeholder="Search conversations…"
aria-label="Search conversations"
/>
<div class="sidebar-kebab-wrapper">
<button
class="btn-kebab"
:class="{ active: sidebarKebabOpen }"
@click="sidebarKebabOpen = !sidebarKebabOpen"
aria-label="List actions"
title="List actions"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
</svg>
</button>
<div v-if="sidebarKebabOpen" class="kebab-menu">
<button
class="kebab-item"
@click="sidebarKebabOpen = false; toggleSelectMode()"
>{{ selectMode ? "Exit select mode" : "Select conversations" }}</button>
</div>
</div>
</div>
</div>
<div v-if="selectMode" class="bulk-bar">
@@ -304,6 +377,10 @@ onUnmounted(() => {
<p v-if="!store.conversations.length" class="empty-msg">
No conversations yet
</p>
<p
v-else-if="convSearchQuery && !groupedConversations.length"
class="empty-msg"
>No matches for {{ convSearchQuery }}</p>
</div>
</aside>
@@ -319,40 +396,68 @@ onUnmounted(() => {
</button>
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
<!-- Research modal trigger -->
<div class="research-wrapper">
<div class="scope-chip-wrapper">
<button
class="btn-attach"
@click="toggleResearchModal"
:disabled="store.streaming || !store.chatReady"
title="Research a topic"
class="scope-chip"
:class="{ pulse: scopePulse }"
@click="scopeDropdownOpen = !scopeDropdownOpen"
title="Change RAG scope"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<span class="scope-dot"></span> {{ scopeLabel }}
</button>
<div v-if="showResearchModal" class="research-modal">
<div class="research-modal-header">Research topic</div>
<input
class="research-topic-input"
v-model="researchTopic"
placeholder="e.g. quantum computing"
@keydown.enter="submitResearch"
@keydown.escape="showResearchModal = false"
/>
<div class="research-modal-actions">
<button class="btn-research-cancel" @click="showResearchModal = false">Cancel</button>
<button class="btn-research-go" @click="submitResearch" :disabled="!researchTopic.trim()">Go</button>
</div>
<div v-if="scopeDropdownOpen" class="scope-dropdown">
<button
class="scope-option"
:class="{ active: store.ragProjectId === null }"
@click="onScopeSelect(null)"
>Orphan notes only</button>
<button
v-for="p in projects"
:key="p.id"
class="scope-option"
:class="{ active: store.ragProjectId === p.id }"
@click="onScopeSelect(p.id)"
>{{ p.title }}</button>
<button
class="scope-option"
:class="{ active: store.ragProjectId === -1 }"
@click="onScopeSelect(-1)"
>All notes</button>
</div>
</div>
<button
v-if="store.currentConversation.messages.length"
class="btn-summarize"
@click="handleSummarize"
:disabled="summarizing || store.streaming"
>{{ summarizing ? "Summarizing..." : "Summarize as Note" }}</button>
v-if="contextCount > 0"
class="btn-context-toggle"
:class="{ active: contextSidebarOpen }"
@click="toggleContextSidebar"
:title="contextSidebarOpen ? 'Hide context panel' : 'Show context panel'"
>
<span class="context-icon">📎</span>
<span class="context-label">Context</span>
<span class="context-count-badge">{{ contextCount }}</span>
</button>
<div class="header-kebab-wrapper">
<button
class="btn-kebab"
:class="{ active: headerKebabOpen }"
@click="headerKebabOpen = !headerKebabOpen"
aria-label="Conversation actions"
title="Conversation actions"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
</svg>
</button>
<div v-if="headerKebabOpen" class="kebab-menu kebab-menu--right">
<button
class="kebab-item"
:disabled="!store.currentConversation.messages.length || summarizing || store.streaming"
@click="headerKebabOpen = false; handleSummarize()"
>{{ summarizing ? "Summarizing…" : "Summarize as Note" }}</button>
</div>
</div>
</div>
<ChatPanel
@@ -397,12 +502,12 @@ onUnmounted(() => {
.sidebar-top-bar {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
}
.btn-new-conv {
flex: 1;
padding: 0.5rem;
background: var(--color-primary);
color: #fff;
@@ -412,23 +517,31 @@ onUnmounted(() => {
font-size: 0.9rem;
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-new-conv--full { width: 100%; }
.btn-new-conv:hover {
opacity: 0.9;
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
}
.btn-select-mode {
background: none;
.sidebar-search-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.conv-search-input {
flex: 1;
min-width: 0;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: 0.82rem;
padding: 0.4rem 0.6rem;
cursor: pointer;
white-space: nowrap;
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
outline: none;
font-family: inherit;
}
.btn-select-mode:hover,
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); }
.conv-search-input:focus { border-color: var(--color-primary); }
.conv-search-input::placeholder { color: var(--color-text-muted); }
.bulk-bar {
display: flex;
@@ -522,12 +635,12 @@ onUnmounted(() => {
overflow: hidden;
}
/* ChatPanel fills the remaining space in chat-main */
/* ChatPanel fills the remaining space in chat-main. Layout (grid vs
* flex) is owned by ChatPanel's own .chat-full root — don't force a
* display here or it overrides the grid. */
.chat-panel-fill {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-header {
@@ -548,87 +661,138 @@ onUnmounted(() => {
min-width: 0;
}
.btn-summarize {
padding: 0.3rem 0.75rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.btn-summarize:hover:not(:disabled) {
background: var(--color-primary); color: #fff; border-color: var(--color-primary);
}
.btn-summarize:disabled { opacity: 0.6; cursor: default; }
.btn-attach {
/* Kebab button + dropdown menu — shared by header and sidebar */
.btn-kebab {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
opacity: 0.6;
padding: 0.2rem;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.btn-attach:hover { opacity: 1; }
.btn-attach:disabled { opacity: 0.25; cursor: default; }
.btn-kebab:hover { color: var(--color-text); background: var(--color-bg-secondary); }
.btn-kebab.active { color: var(--color-primary); }
.research-wrapper { position: relative; }
.research-modal {
.header-kebab-wrapper,
.sidebar-kebab-wrapper {
position: relative;
}
/* RAG scope chip (header) */
.scope-chip-wrapper { position: relative; flex-shrink: 0; }
.scope-chip {
display: flex;
align-items: center;
gap: 0.3rem;
background: none;
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 0.2rem 0.65rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
@keyframes pulse-chip {
0%, 100% { border-color: var(--color-border); }
50% {
border-color: var(--color-primary);
color: var(--color-primary);
box-shadow: 0 0 6px rgba(99, 102, 241, 0.4);
}
}
.scope-dot { font-size: 0.6rem; }
.scope-dropdown {
position: absolute;
top: calc(100% + 8px);
top: calc(100% + 4px);
right: 0;
min-width: 200px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 20;
overflow: hidden;
}
.scope-option {
display: block;
width: 100%;
padding: 0.45rem 0.75rem;
text-align: left;
background: none;
border: none;
font-size: 0.85rem;
color: var(--color-text);
cursor: pointer;
}
.scope-option:hover { background: var(--color-bg-secondary); }
.scope-option.active { color: var(--color-primary); font-weight: 600; }
/* Context toggle button (header) */
.btn-context-toggle {
display: flex;
align-items: center;
gap: 0.3rem;
background: none;
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 0.2rem 0.55rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s, background 0.15s;
font-family: inherit;
}
.btn-context-toggle:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-context-toggle.active {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.context-icon { font-size: 0.85rem; line-height: 1; }
.context-count-badge {
font-size: 0.65rem;
font-weight: 700;
background: var(--color-bg);
padding: 0.05rem 0.35rem;
border-radius: 8px;
color: var(--color-text);
}
.kebab-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 180px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 20px var(--color-shadow);
padding: 0.75rem;
min-width: 280px;
padding: 0.25rem;
z-index: 20;
}
.research-modal-header {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.research-topic-input {
.kebab-menu--right { left: auto; right: 0; }
.kebab-item {
display: block;
width: 100%;
padding: 0.45rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
text-align: left;
background: none;
border: none;
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
font-size: 0.85rem;
padding: 0.45rem 0.65rem;
border-radius: var(--radius-sm);
cursor: pointer;
}
.research-topic-input:focus { border-color: var(--color-primary); }
.research-modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
justify-content: flex-end;
}
.btn-research-cancel {
background: none; border: 1px solid var(--color-border);
border-radius: var(--radius-sm); padding: 0.3rem 0.65rem;
font-size: 0.85rem; cursor: pointer; color: var(--color-text-muted);
}
.btn-research-cancel:hover { border-color: var(--color-text-muted); }
.btn-research-go {
background: var(--color-primary); color: #fff; border: none;
border-radius: var(--radius-sm); padding: 0.3rem 0.75rem;
font-size: 0.85rem; cursor: pointer; font-weight: 600;
}
.btn-research-go:disabled { opacity: 0.4; cursor: default; }
.btn-research-go:not(:disabled):hover { opacity: 0.9; }
.kebab-item:hover:not(:disabled) { background: var(--color-bg-secondary); color: var(--color-primary); }
.kebab-item:disabled { opacity: 0.4; cursor: default; }
.no-conversation {
flex: 1;
@@ -639,6 +803,9 @@ onUnmounted(() => {
gap: 1rem;
color: var(--color-text-muted);
}
.no-conversation .btn-new-conv {
flex: none;
}
.empty-msg {
color: var(--color-text-muted);
+9 -9
View File
@@ -569,7 +569,7 @@ function formatUpcomingTime(event: EventEntry): string {
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
margin-bottom: 1.75rem;
box-shadow: 0 2px 16px rgba(99, 102, 241, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
box-shadow: 0 2px 16px rgba(124, 58, 237, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
}
.hero-top {
display: flex;
@@ -605,20 +605,20 @@ function formatUpcomingTime(event: EventEntry): string {
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1.1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.3);
box-shadow: 0 1px 6px rgba(124, 58, 237, 0.3);
transition: opacity 0.15s, box-shadow 0.15s;
flex-shrink: 0;
}
.btn-workspace-hero:hover {
opacity: 0.9;
box-shadow: 0 3px 14px rgba(99, 102, 241, 0.45);
box-shadow: 0 3px 14px rgba(124, 58, 237, 0.45);
}
.hero-milestones { margin-bottom: 0.75rem; }
@@ -760,8 +760,8 @@ function formatUpcomingTime(event: EventEntry): string {
font-weight: 500;
}
.urgency-in-progress {
background: color-mix(in srgb, #6366f1 15%, transparent);
color: #6366f1;
background: color-mix(in srgb, #7c3aed 15%, transparent);
color: #7c3aed;
}
.urgency-todo {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
@@ -892,14 +892,14 @@ function formatUpcomingTime(event: EventEntry): string {
width: 100%;
}
.upcoming-event-card:hover {
border-color: var(--color-primary, #6366f1);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card));
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card));
}
.upcoming-event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
background: var(--color-primary);
flex-shrink: 0;
margin-top: 4px;
}
+253 -77
View File
@@ -15,7 +15,7 @@ const chatStore = useChatStore();
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list";
note_type: "note" | "person" | "place" | "list" | "task";
title: string;
snippet: string;
tags: string[];
@@ -27,12 +27,20 @@ interface KnowledgeItem {
relationship?: string;
email?: string;
phone?: string;
birthday?: string;
organization?: string;
address?: string;
hours?: string;
website?: string;
category?: string;
item_count?: number;
checked_count?: number;
list_items?: { text: string; checked: boolean }[];
body?: string;
// Task-specific
status?: string;
priority?: string;
due_date?: string;
}
interface UpcomingEvent {
@@ -44,7 +52,7 @@ interface UpcomingEvent {
// ─── Filter state ─────────────────────────────────────────────────────────────
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
const activeTag = ref("");
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
@@ -69,7 +77,18 @@ const newNoteMenuOpen = ref(false);
function createNew(type: string) {
newNoteMenuOpen.value = false;
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
if (type === "task") {
router.push("/tasks/new");
} else {
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
}
}
function onClickOutsideNewNote(e: MouseEvent) {
const wrap = document.querySelector('.new-note-wrap');
if (wrap && !wrap.contains(e.target as Node)) {
newNoteMenuOpen.value = false;
}
}
// ─── Two-tier pagination ──────────────────────────────────────────────────────
@@ -228,7 +247,7 @@ const chatCollapsed = ref(false);
const chatConvId = ref<number | null>(null);
async function onMinichatSubmit(payload: { content: string; contextNoteId?: number }) {
if (!chatConvId.value) {
const conv = await chatStore.createConversation("Knowledge chat");
const conv = await chatStore.createConversation();
chatConvId.value = conv.id;
await chatStore.fetchConversation(conv.id);
} else if (chatStore.currentConversation?.id !== chatConvId.value) {
@@ -236,7 +255,7 @@ async function onMinichatSubmit(payload: { content: string; contextNoteId?: numb
}
chatOpen.value = true;
chatCollapsed.value = false;
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, true);
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, false);
}
// ─── Auto-refresh cards when chat creates/edits notes or tasks ───────────────
@@ -307,8 +326,17 @@ async function toggleListItem(item: KnowledgeItem, index: number) {
// ─── Navigation helpers ───────────────────────────────────────────────────────
function isOverdue(item: KnowledgeItem): boolean {
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
return new Date(item.due_date) < new Date(new Date().toDateString());
}
function openItem(item: KnowledgeItem) {
router.push(`/notes/${item.id}`);
if (item.note_type === 'task') {
router.push(`/tasks/${item.id}`);
} else {
router.push(`/notes/${item.id}`);
}
}
function formatDate(iso: string): string {
@@ -344,6 +372,7 @@ function setupObserver() {
// ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(async () => {
document.addEventListener('click', onClickOutsideNewNote);
await reset();
fetchTags();
fetchCounts();
@@ -353,6 +382,7 @@ onMounted(async () => {
});
onUnmounted(() => {
document.removeEventListener('click', onClickOutsideNewNote);
if (searchDebounce) clearTimeout(searchDebounce);
observer?.disconnect();
});
@@ -392,13 +422,30 @@ onUnmounted(() => {
<aside class="filter-panel">
<!-- New note button -->
<div class="new-note-wrap">
<button class="btn-new-note" @click="createNew('note')">+ New note</button>
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type"></button>
<button class="btn-new-note" @click="newNoteMenuOpen = !newNoteMenuOpen">
<span class="btn-new-icon">+</span> New
</button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('note')">Note</button>
<button @click="createNew('person')">Person</button>
<button @click="createNew('place')">Place</button>
<button @click="createNew('list')">List</button>
<button @click="createNew('note')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Note
</button>
<button @click="createNew('task')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Task
</button>
<button @click="createNew('person')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Person
</button>
<button @click="createNew('place')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
Place
</button>
<button @click="createNew('list')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
List
</button>
</div>
</div>
@@ -413,11 +460,11 @@ onUnmounted(() => {
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
</button>
<button
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
:key="val"
class="filter-btn"
:class="{ active: activeType === val }"
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
>
<span class="filter-btn-label">{{ label }}</span>
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
@@ -474,9 +521,8 @@ onUnmounted(() => {
<!-- Loading / empty -->
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading</div>
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
<p>Nothing here yet.</p>
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">Try clearing the filters.</p>
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
<p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
</div>
<!-- Card grid -->
@@ -493,6 +539,7 @@ onUnmounted(() => {
<span v-if="item.note_type === 'note'">Note</span>
<span v-else-if="item.note_type === 'person'">Person</span>
<span v-else-if="item.note_type === 'place'">Place</span>
<span v-else-if="item.note_type === 'task'">Task</span>
<span v-else>List</span>
</span>
@@ -502,11 +549,13 @@ onUnmounted(() => {
<!-- Person specifics -->
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
<!-- Place specifics -->
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
@@ -533,6 +582,26 @@ onUnmounted(() => {
</span>
</div>
<!-- Task specifics -->
<div v-else-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges">
<span class="status-badge" :class="`status--${item.status}`">
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
</span>
<span
v-if="item.priority && item.priority !== 'none'"
class="priority-badge"
:class="`priority--${item.priority}`"
>{{ item.priority }}</span>
</div>
<span
v-if="item.due_date"
class="task-due"
:class="{ 'task-overdue': isOverdue(item) }"
>{{ formatDate(item.due_date) }}</span>
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
<!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
@@ -652,17 +721,17 @@ onUnmounted(() => {
gap: 5px;
padding: 3px 10px;
border-radius: 20px;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.2);
background: rgba(124, 58, 237, 0.1);
border: 1px solid rgba(124, 58, 237, 0.2);
color: var(--color-text);
text-decoration: none;
transition: background 0.15s;
}
.today-event-chip:hover { background: rgba(99, 102, 241, 0.18); }
.today-event-chip:hover { background: rgba(124, 58, 237, 0.18); }
.chip-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: #6366f1;
background: #7c3aed;
flex-shrink: 0;
}
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
@@ -677,7 +746,7 @@ onUnmounted(() => {
font-size: 0.78rem;
}
.today-link {
color: var(--color-primary, #6366f1);
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
opacity: 0.85;
@@ -703,73 +772,89 @@ onUnmounted(() => {
background: var(--color-bg-secondary);
}
.filter-section { margin-bottom: 20px; }
.filter-section + .filter-section::before {
content: '· · ·';
display: block;
text-align: center;
color: rgba(124, 58, 237, 0.3);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
}
.filter-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-muted);
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.72rem;
letter-spacing: 0.02em;
color: var(--color-primary);
margin-bottom: 6px;
padding: 0 4px;
}
/* ── New note button ─────────────────────────────────────── */
/* ── New item button ─────────────────────────────────────── */
.new-note-wrap {
position: relative;
display: flex;
margin-bottom: 16px;
}
.btn-new-note {
flex: 1;
padding: 7px 10px;
border-radius: 8px 0 0 8px;
border: 1px solid rgba(99, 102, 241, 0.4);
border-right: none;
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
width: 100%;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 10px;
border: none;
background: var(--gradient-cta);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
text-align: left;
transition: background 0.15s;
font-weight: 600;
transition: box-shadow 0.15s;
}
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
.btn-new-chevron {
padding: 7px 9px;
border-radius: 0 8px 8px 0;
border: 1px solid rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
font-size: 0.78rem;
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
.btn-new-icon {
font-size: 1.1rem;
line-height: 1;
transition: background 0.15s, transform 0.15s;
font-weight: 300;
}
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
.btn-new-chevron.open { transform: scaleY(-1); }
.new-note-menu {
position: absolute;
top: calc(100% + 4px);
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--color-bg-tertiary, #1a1b1e);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
z-index: 50;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
padding: 4px 0;
}
.new-note-menu button {
display: block;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 7px 12px;
padding: 9px 14px;
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 0.84rem;
text-align: left;
transition: background 0.12s;
transition: background 0.12s, color 0.12s;
}
.new-note-menu button:hover {
background: var(--color-primary-tint);
color: var(--color-primary);
}
.new-note-menu button svg {
flex-shrink: 0;
opacity: 0.6;
}
.new-note-menu button:hover svg {
opacity: 1;
stroke: var(--color-primary);
}
.new-note-menu button:hover { background: rgba(99, 102, 241, 0.12); }
.filter-btn {
display: flex;
@@ -790,8 +875,8 @@ onUnmounted(() => {
}
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
.filter-btn.active {
background: rgba(99, 102, 241, 0.15);
color: var(--color-primary, #818cf8);
background: var(--color-primary-wash);
color: var(--color-primary);
opacity: 1;
}
.filter-btn-label { flex: 1; }
@@ -807,8 +892,8 @@ onUnmounted(() => {
flex-shrink: 0;
}
.filter-btn.active .filter-count {
background: rgba(99, 102, 241, 0.2);
color: var(--color-primary, #818cf8);
background: rgba(124, 58, 237, 0.2);
color: var(--color-primary);
}
.filter-tag { font-size: 0.78rem; }
@@ -853,7 +938,7 @@ onUnmounted(() => {
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--color-primary, #6366f1); }
.search-input:focus { border-color: var(--color-primary); }
.sort-select {
padding: 7px 10px;
border-radius: 8px;
@@ -880,9 +965,9 @@ onUnmounted(() => {
}
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
.btn-graph.active {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.35);
color: var(--color-primary, #818cf8);
background: var(--color-primary-wash);
border-color: rgba(124, 58, 237, 0.35);
color: var(--color-primary);
}
/* ── Card grid ───────────────────────────────────────────── */
@@ -890,6 +975,8 @@ onUnmounted(() => {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
@@ -911,16 +998,56 @@ onUnmounted(() => {
overflow: hidden;
}
.k-card:hover {
border-color: rgba(255,255,255,0.14);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(124, 58, 237, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: rgba(124, 58, 237, 0.35);
}
/* Type accent strip */
.k-card--person { border-left: 3px solid #10b981; }
.k-card--place { border-left: 3px solid #f59e0b; }
.k-card--list { border-left: 3px solid #38bdf8; }
.k-card--note { border-left: 3px solid #6366f1; }
/* Type-specific card DNA */
.k-card--note { border-color: rgba(124, 58, 237, 0.20); }
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
.k-card--place { border-color: rgba(245, 158, 11, 0.18); }
.k-card--list { border-color: rgba(56, 189, 248, 0.18); }
/* Top gradient bars */
.k-card--note::before,
.k-card--task::before,
.k-card--list::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 3px;
border-radius: 14px 14px 0 0;
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, #7c3aed, #a78bfa);
}
.k-card--task::before {
right: 0;
background: linear-gradient(90deg, #d4a017, #fbbf24);
}
.k-card--list::before {
right: 0;
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
}
/* Corner accents for entity types */
.k-card--person::after,
.k-card--place::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 60px;
height: 60px;
border-radius: 0 14px 0 60px;
pointer-events: none;
}
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
/* Type badge */
.type-badge {
@@ -934,10 +1061,11 @@ onUnmounted(() => {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge--note { background: rgba(99,102,241,0.15); color: #818cf8; }
.badge--note { background: rgba(99,102,241,0.15); color: #a78bfa; }
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
.k-card-body { flex: 1; padding-right: 40px; }
.k-card-title {
@@ -1038,7 +1166,48 @@ onUnmounted(() => {
background: rgba(255,255,255,0.05);
color: var(--color-muted);
}
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
/* ── Task card ──────────────────────────────────────────── */
.k-card-task {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-badges {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.status-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
.priority-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
.task-due {
font-size: 0.78rem;
color: var(--color-accent-warm);
}
.task-overdue {
color: var(--color-overdue);
font-weight: 500;
}
/* ── Empty / loading ─────────────────────────────────────── */
.knowledge-empty {
@@ -1053,6 +1222,13 @@ onUnmounted(() => {
gap: 6px;
}
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
.empty-narrator {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 1rem;
color: var(--color-accent-warm);
opacity: 0.85;
}
/* ── Sentinel ────────────────────────────────────────────── */
.scroll-sentinel {
+403 -71
View File
@@ -38,6 +38,81 @@ const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const sidebarOpen = ref(true);
const notesExpanded = ref(false);
// ── List builder ─────────────────────────────────────────────────────────────
interface ListItem {
text: string;
checked: boolean;
}
const listItems = ref<ListItem[]>([]);
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
const lines = bodyText.split('\n');
const items: ListItem[] = [];
const extraLines: string[] = [];
let pastList = false;
for (const line of lines) {
const stripped = line.trimStart();
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
} else if (!pastList && stripped === '' && items.length > 0) {
pastList = true;
} else {
pastList = true;
extraLines.push(line);
}
}
return { items, extra: extraLines.join('\n').trim() };
}
function serializeListToBody(): string {
const listPart = listItems.value
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
const extraPart = body.value.trim();
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
}
function addListItem(afterIndex?: number) {
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
listItems.value.splice(idx, 0, { text: '', checked: false });
markDirty();
nextTick(() => {
listItemRefs.value[idx]?.focus();
});
}
function removeListItem(index: number) {
if (listItems.value.length <= 1) return;
listItems.value.splice(index, 1);
markDirty();
nextTick(() => {
const focusIdx = Math.max(0, index - 1);
listItemRefs.value[focusIdx]?.focus();
});
}
function onListItemKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addListItem(index);
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
e.preventDefault();
removeListItem(index);
}
}
function onListItemInput(_index: number) {
markDirty();
}
function toggleListItemCheck(index: number) {
listItems.value[index].checked = !listItems.value[index].checked;
markDirty();
}
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
@@ -49,6 +124,15 @@ const noteId = computed(() =>
);
const isEditing = computed(() => noteId.value !== null);
const titlePlaceholder = computed(() => {
switch (noteType.value) {
case 'person': return 'Name';
case 'place': return 'Place name';
case 'list': return 'List title';
default: return 'Title';
}
});
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist — pass noteId for draft persistence
@@ -219,6 +303,13 @@ onMounted(async () => {
milestoneId.value = store.currentNote.milestone_id ?? null;
noteType.value = (store.currentNote.note_type as NoteType) || "note";
Object.assign(entityMeta, store.currentNote.metadata || {});
notesExpanded.value = !!(store.currentNote.body || '').trim();
if (noteType.value === 'list') {
const parsed = parseListFromBody(body.value);
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
body.value = parsed.extra;
notesExpanded.value = !!parsed.extra;
}
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
@@ -233,6 +324,9 @@ onMounted(async () => {
if (qt && ["note", "person", "place", "list"].includes(qt)) {
noteType.value = qt as NoteType;
}
if (noteType.value === 'list') {
listItems.value = [{ text: '', checked: false }];
}
}
// Restore pending draft if any
@@ -243,6 +337,9 @@ onMounted(async () => {
// No draft — normal
}
await nextTick();
titleRef.value?.focus();
// Initial link suggestions
fetchLinkSuggestions();
});
@@ -250,11 +347,12 @@ onMounted(async () => {
async function save() {
if (saving.value) return;
saving.value = true;
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
try {
if (isEditing.value) {
await store.updateNote(noteId.value!, {
title: title.value,
body: body.value,
body: finalBody,
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
@@ -273,7 +371,7 @@ async function save() {
} else {
const note = await store.createNote({
title: title.value,
body: body.value,
body: finalBody,
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
@@ -304,7 +402,7 @@ async function confirmDelete() {
await store.deleteNote(noteId.value);
dirty.value = false;
toast.show("Note deleted");
router.push("/notes");
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch {
toast.show("Failed to delete note", "error");
}
@@ -314,9 +412,10 @@ async function confirmDelete() {
async function doAutoSave() {
if (!isEditing.value || saving.value) return;
saving.value = true;
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
try {
await store.updateNote(noteId.value!, {
title: title.value, body: body.value, tags: tags.value,
title: title.value, body: finalBody, tags: tags.value,
project_id: projectId.value, milestone_id: milestoneId.value,
note_type: noteType.value, metadata: { ...entityMeta },
});
@@ -346,7 +445,7 @@ onUnmounted(() => assist.clearSelection());
<main class="editor-page note-editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/notes" class="btn-back"> Notes</router-link>
<router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
@@ -357,7 +456,7 @@ onUnmounted(() => assist.clearSelection());
ref="titleRef"
v-model="title"
type="text"
placeholder="Title"
:placeholder="titlePlaceholder"
class="title-input"
@input="markDirty"
@keydown.ctrl.s.prevent="save"
@@ -370,48 +469,181 @@ onUnmounted(() => assist.clearSelection());
<!-- Main column -->
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<div class="body-tabs-row">
<div class="editor-tabs">
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
<!-- Person form -->
<template v-if="noteType === 'person'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Relationship</label>
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Birthday</label>
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Email</label>
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Organization</label>
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
</div>
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
</div>
<!-- Streaming preview -->
<template v-if="assist.state.value === 'streaming'">
<div class="stream-label">Generating...</div>
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
</template>
<!-- Review: full-document diff -->
<template v-else-if="assist.state.value === 'review'">
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<!-- Normal editor -->
<template v-else>
<Transition name="tab-fade" mode="out-in">
<div v-if="!showPreview" class="body-editor-wrap">
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown..."
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div
v-else
class="preview-pane prose"
v-html="renderedPreview"
/>
</Transition>
</div>
</template>
<!-- Error from assist -->
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
<!-- Place form -->
<template v-else-if="noteType === 'place'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Hours</label>
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 9am5pm" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Website</label>
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Category</label>
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- List builder -->
<template v-else-if="noteType === 'list'">
<div class="list-builder">
<div
v-for="(item, idx) in listItems"
:key="idx"
class="lb-item"
>
<input
type="checkbox"
:checked="item.checked"
@change="toggleListItemCheck(idx)"
class="lb-check"
tabindex="-1"
/>
<input
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
v-model="item.text"
class="lb-text"
placeholder="List item..."
@keydown="onListItemKeydown($event, idx)"
@input="onListItemInput(idx)"
/>
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">&times;</button>
</div>
<button class="lb-add" @click="addListItem()">+ Add item</button>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- Generic note editor -->
<template v-else>
<div class="body-tabs-row">
<div class="editor-tabs">
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
</div>
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
</div>
<!-- Streaming preview -->
<template v-if="assist.state.value === 'streaming'">
<div class="stream-label">Generating...</div>
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
</template>
<!-- Review: full-document diff -->
<template v-else-if="assist.state.value === 'review'">
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<!-- Normal editor -->
<template v-else>
<Transition name="tab-fade" mode="out-in">
<div v-if="!showPreview" class="body-editor-wrap">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div
v-else
class="preview-pane prose"
v-html="renderedPreview"
/>
</Transition>
</template>
<!-- Error from assist -->
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
</template>
</div>
<!-- Sidebar -->
@@ -476,38 +708,6 @@ onUnmounted(() => assist.clearSelection());
</select>
</div>
<!-- Person metadata -->
<template v-if="noteType === 'person'">
<div class="sb-field">
<label class="sb-label">Relationship</label>
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Email</label>
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
</template>
<!-- Place metadata -->
<template v-if="noteType === 'place'">
<div class="sb-field">
<label class="sb-label">Address</label>
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Hours</label>
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. 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">
@@ -731,7 +931,7 @@ onUnmounted(() => assist.clearSelection());
transition: border-color 0.15s;
}
.sb-select:focus, .sb-input:focus {
border-color: var(--color-primary, #6366f1);
border-color: var(--color-primary);
}
/* Tag suggest row inside sidebar */
@@ -819,6 +1019,138 @@ onUnmounted(() => assist.clearSelection());
letter-spacing: 0.05em;
}
/* ── Entity form (Person / Place) ───────────────────────── */
.entity-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.ef-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
color: var(--color-primary);
}
.ef-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.ef-input:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.ef-input::placeholder {
color: var(--color-text-muted);
}
/* ── Notes section (collapsible TipTap) ─────────────────── */
.notes-section {
margin-top: 16px;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.notes-toggle {
background: none;
border: none;
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 0;
}
.notes-toggle:hover {
color: var(--color-text);
}
.notes-editor-wrap {
margin-top: 8px;
}
/* ── List builder ───────────────────────────────────────── */
.list-builder {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 0;
}
.lb-item {
display: flex;
align-items: center;
gap: 8px;
}
.lb-check {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.lb-text {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.lb-text:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.lb-text::placeholder {
color: var(--color-text-muted);
}
.lb-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s;
}
.lb-item:hover .lb-delete,
.lb-text:focus ~ .lb-delete {
opacity: 1;
}
.lb-delete:hover {
color: var(--color-danger);
}
.lb-add {
background: none;
border: 1px dashed var(--color-border);
border-radius: 8px;
padding: 7px 12px;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
margin-top: 4px;
transition: border-color 0.15s, color 0.15s;
}
.lb-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Narrow screen: sidebar collapses */
@media (max-width: 720px) {
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
+3 -3
View File
@@ -350,17 +350,17 @@ async function convertToTask() {
padding: 0.45rem 1.1rem;
border: none;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-edit:hover {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
box-shadow: var(--glow-cta-hover);
opacity: 0.95;
color: #fff;
}
-376
View File
@@ -1,376 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
import NoteCard from "@/components/NoteCard.vue";
import TagPill from "@/components/TagPill.vue";
import PaginationBar from "@/components/PaginationBar.vue";
type ViewMode = "grid" | "list";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
const searchBarRef = ref<{ focus: () => void } | null>(null);
function onFocusSearch() {
searchBarRef.value?.focus();
}
const { activeIndex } = useListKeyboardNavigation(
computed(() => store.notes),
(note) => router.push(`/notes/${note.id}`),
);
const viewMode = ref<ViewMode>(
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
);
function setViewMode(mode: ViewMode) {
viewMode.value = mode;
localStorage.setItem("fabled-notes-view-mode", mode);
}
onMounted(() => {
const tag = route.query.tag;
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.setTagFilters(tags);
} else {
store.refresh();
}
document.addEventListener("shortcut:focus-search", onFocusSearch);
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch);
});
watch(
() => route.query.tag,
(tag) => {
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.setTagFilters(tags);
}
}
);
function onSearch(q: string) {
store.setSearch(q);
}
function onTagClick(tag: string) {
store.addTagFilter(tag);
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
}
function onTagDismiss(tag: string) {
store.removeTagFilter(tag);
const newQuery = { ...route.query };
if (store.activeTagFilters.length > 0) {
newQuery.tag = store.activeTagFilters;
} else {
delete newQuery.tag;
}
router.replace({ query: newQuery });
}
function onSortChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setSort(value, store.sortOrder);
}
function toggleOrder() {
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
}
function onOffsetUpdate(offset: number) {
store.setOffset(offset);
}
</script>
<template>
<main class="notes-list">
<div class="header">
<h1>Notes</h1>
<router-link to="/notes/new" class="btn-new">+ New Note</router-link>
</div>
<SearchBar ref="searchBarRef" @search="onSearch" />
<div class="controls">
<div class="sort-controls">
<select :value="store.sortField" @change="onSortChange" class="sort-select">
<option value="updated_at">Updated</option>
<option value="created_at">Created</option>
<option value="title">Title</option>
</select>
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
{{ store.sortOrder === "asc" ? "↑" : "↓" }}
</button>
</div>
<!-- View mode toggle -->
<div class="view-toggle">
<button
:class="['toggle-btn', { active: viewMode === 'grid' }]"
title="Grid view"
@click="setViewMode('grid')"
></button>
<button
:class="['toggle-btn', { active: viewMode === 'list' }]"
title="Compact list"
@click="setViewMode('list')"
></button>
</div>
</div>
<div v-if="store.activeTagFilters.length" class="active-filters">
<span class="filter-label">Filtering by:</span>
<TagPill
v-for="tag in store.activeTagFilters"
:key="tag"
:tag="tag"
dismissible
@dismiss="onTagDismiss"
/>
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<div v-if="store.loading" class="note-list-skeleton">
<div v-if="viewMode === 'grid'" class="skeleton-grid">
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
</div>
<div v-else class="skeleton-rows">
<div class="skeleton-row" v-for="i in 8" :key="i"></div>
</div>
</div>
<div v-else-if="store.notes.length === 0" class="empty-state">
<template v-if="store.searchQuery || store.activeTagFilters.length">
<p class="empty-title">No notes match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
<template v-else>
<p class="empty-title">No notes yet</p>
<p class="empty-subtitle">Create your first note to get started.</p>
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</template>
</div>
<!-- Grid view -->
<div v-else-if="viewMode === 'grid'" class="cards-grid">
<div
v-for="(note, i) in store.notes"
:key="note.id"
:class="{ 'kb-active-item': activeIndex === i }"
>
<NoteCard :note="note" @tag-click="onTagClick" />
</div>
</div>
<!-- Compact list view -->
<div v-else class="cards-list">
<div
v-for="(note, i) in store.notes"
:key="note.id"
:class="{ 'kb-active-item': activeIndex === i }"
>
<NoteCard :note="note" compact @tag-click="onTagClick" />
</div>
</div>
<PaginationBar
:total="store.total"
:limit="store.limit"
:offset="store.offset"
@update:offset="onOffsetUpdate"
/>
</main>
</template>
<style scoped>
.notes-list {
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h1 {
margin: 0;
}
.btn-new {
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-new:hover {
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
}
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.view-toggle {
display: flex;
gap: 0.2rem;
}
.toggle-btn {
padding: 0.3rem 0.55rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.toggle-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.active-filters {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.clear-filters {
background: none;
border: none;
color: var(--color-danger);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
/* Skeleton loading */
.note-list-skeleton {
margin-top: 1rem;
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
}
.skeleton-card {
height: 140px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.skeleton-row {
height: 40px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Grid layout */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
.cards-grid > * {
min-width: 0;
}
/* Compact list layout */
.cards-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 1rem;
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-cta:hover {
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.kb-active-item {
outline: 2px solid var(--color-primary);
border-radius: var(--radius-md);
}
</style>
+52 -3
View File
@@ -20,7 +20,7 @@ interface Project {
title: string;
description: string | null;
goal: string | null;
status: "active" | "completed" | "archived";
status: "active" | "paused" | "completed" | "archived";
color: string | null;
auto_summary: string | null;
permission?: string;
@@ -40,7 +40,7 @@ const toast = useToastStore();
const projects = ref<Project[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"all" | "active" | "completed" | "archived">("all");
const activeTab = ref<"all" | "active" | "paused" | "completed" | "archived">("all");
// New project modal
const showNewProjectModal = ref(false);
@@ -103,6 +103,7 @@ async function createProject() {
function statusLabel(status: Project["status"]): string {
if (status === "active") return "Active";
if (status === "paused") return "Paused";
if (status === "completed") return "Completed";
if (status === "archived") return "Archived";
return status;
@@ -112,6 +113,14 @@ function truncate(text: string | null, max = 120): string {
if (!text) return "";
return text.length > max ? text.slice(0, max) + "..." : text;
}
function overallPct(project: Project): { total: number; pct: number } {
const counts = project.summary?.task_counts;
if (!counts) return { total: 0, pct: 0 };
const total = (counts.todo ?? 0) + (counts.in_progress ?? 0) + (counts.done ?? 0);
const pct = total > 0 ? Math.round((counts.done ?? 0) / total * 100) : 0;
return { total, pct };
}
</script>
<template>
@@ -124,7 +133,7 @@ function truncate(text: string | null, max = 120): string {
<!-- Filter tabs -->
<div class="filter-tabs">
<button
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
:key="tab"
:class="['tab-btn', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -163,6 +172,18 @@ function truncate(text: string | null, max = 120): string {
</p>
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
<!-- Overall completion bar -->
<div
v-if="overallPct(project).total > 0"
class="overall-bar-row"
:title="`${project.summary?.task_counts.done ?? 0} of ${overallPct(project).total} tasks complete`"
>
<div class="overall-bar-track">
<div class="overall-bar-fill" :style="{ width: overallPct(project).pct + '%' }"></div>
</div>
<span class="overall-bar-pct">{{ overallPct(project).pct }}%</span>
</div>
<!-- Milestone progress bars -->
<div
v-if="project.summary?.milestone_summary?.length"
@@ -416,6 +437,34 @@ function truncate(text: string | null, max = 120): string {
line-height: 1.45;
}
.overall-bar-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
margin-top: 0.1rem;
}
.overall-bar-track {
flex: 1;
height: 6px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
.overall-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--color-primary);
transition: width 0.3s ease;
}
.overall-bar-pct {
color: var(--color-text-secondary);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
font-weight: 600;
}
.milestone-bars {
display: flex;
flex-direction: column;
+19 -7
View File
@@ -23,7 +23,7 @@ interface Project {
title: string;
description: string | null;
goal: string | null;
status: "active" | "completed" | "archived";
status: "active" | "paused" | "completed" | "archived";
color: string | null;
auto_summary: string | null;
permission?: string;
@@ -118,6 +118,14 @@ function toggleMilestoneCollapse(id: number) {
}
}
function autoCollapseCompleted(msList: Milestone[]) {
for (const ms of msList) {
if (ms.total > 0 && ms.completed === ms.total) {
collapsedMilestones.value.add(ms.id);
}
}
}
async function loadProject() {
loading.value = true;
error.value = null;
@@ -130,6 +138,7 @@ async function loadProject() {
editStatus.value = data.status;
editDirty.value = false;
milestones.value = data.summary?.milestone_summary ?? [];
autoCollapseCompleted(milestones.value);
} catch {
error.value = "Failed to load project.";
} finally {
@@ -143,6 +152,7 @@ async function loadMilestones() {
`/api/projects/${projectId.value}/milestones`
);
milestones.value = data.milestones;
autoCollapseCompleted(milestones.value);
} catch {
// silent
}
@@ -395,6 +405,7 @@ async function confirmDelete() {
<label class="edit-label">Status</label>
<select v-model="editStatus" class="edit-select">
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
@@ -676,17 +687,17 @@ async function confirmDelete() {
align-items: center;
gap: 0.35rem;
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-workspace:hover { box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42); opacity: 0.95; color: #fff; }
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
.btn-share {
padding: 0.4rem 0.8rem;
@@ -752,6 +763,7 @@ async function confirmDelete() {
flex-shrink: 0;
}
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--color-accent-warm) 14%, transparent); color: var(--color-accent-warm); border: 1px solid color-mix(in srgb, var(--color-accent-warm) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
@@ -857,7 +869,7 @@ async function confirmDelete() {
.btn-save-panel {
padding: 0.45rem 0.9rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: var(--radius-sm);
@@ -866,10 +878,10 @@ async function confirmDelete() {
font-weight: 600;
font-family: inherit;
width: 100%;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25);
box-shadow: var(--glow-soft);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); opacity: 0.95; }
.btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); opacity: 0.95; }
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
/* ── Content area ────────────────────────────────────────────── */
+233 -29
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
@@ -94,17 +94,54 @@ const mcpInfo = ref<{ available: boolean; filename: string | null } | null>(null
const mcpInfoLoading = ref(false);
const origin = window.location.origin;
const mcpConfigSnippet = JSON.stringify({
const mcpClientTab = ref<'claude-code' | 'claude-desktop' | 'other'>('claude-code');
const copiedSnippetKey = ref<string | null>(null);
const effectiveApiKey = computed(() => newKeyValue.value || '<your-api-key>');
const claudeCodeCommand = computed(() => {
return `claude mcp add --transport stdio --scope user fable \\
--env FABLE_URL=${origin} \\
--env FABLE_API_KEY=${effectiveApiKey.value} \\
-- fable-mcp`;
});
const mcpConfigSnippet = computed(() => JSON.stringify({
mcpServers: {
fable: {
command: "fable-mcp",
env: {
FABLE_URL: window.location.origin,
FABLE_API_KEY: "<your-api-key>",
FABLE_URL: origin,
FABLE_API_KEY: effectiveApiKey.value,
},
},
},
}, null, 2);
}, null, 2));
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for http (non-secure) contexts where clipboard API is unavailable
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}
async function copySnippet(text: string, key: string) {
await copyToClipboard(text);
copiedSnippetKey.value = key;
setTimeout(() => {
if (copiedSnippetKey.value === key) copiedSnippetKey.value = null;
}, 2000);
}
async function loadMcpInfo() {
if (mcpInfo.value !== null) return;
@@ -142,20 +179,7 @@ async function revokeApiKey(id: number) {
}
async function copyApiKey() {
try {
await navigator.clipboard.writeText(newKeyValue.value);
} catch {
// Fallback for http (non-secure) contexts where clipboard API is unavailable
const ta = document.createElement('textarea');
ta.value = newKeyValue.value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
await copyToClipboard(newKeyValue.value);
apiKeyCopied.value = true;
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
}
@@ -428,6 +452,28 @@ const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);
// VAPID key reset (admin)
const vapidResetting = ref(false);
const vapidResetMsg = ref("");
const vapidResetError = ref(false);
async function resetVapidKeys() {
if (!confirm("This will regenerate VAPID keys and clear all push subscriptions. You will need to re-enable notifications afterwards. Continue?")) return;
vapidResetting.value = true;
vapidResetMsg.value = "";
vapidResetError.value = false;
try {
await apiPost("/api/push/reset-vapid", {});
vapidResetMsg.value = "Keys regenerated. Please re-enable notifications in this browser.";
pushStore.checkSubscription();
} catch {
vapidResetError.value = true;
vapidResetMsg.value = "Reset failed — check server logs.";
} finally {
vapidResetting.value = false;
}
}
// CalDAV settings (per-user)
const caldav = ref({
caldav_url: "",
@@ -478,6 +524,8 @@ async function saveAdminVoice() {
adminVoiceSaved.value = true;
setTimeout(() => { adminVoiceSaved.value = false; }, 2000);
voiceTabLoaded.value = false;
// Always update global voice state so mic buttons appear/disappear immediately
await store.checkVoiceStatus();
if (adminVoiceEnabled.value) {
await reloadVoiceModels();
}
@@ -1882,6 +1930,22 @@ function formatUserDate(iso: string): string {
Notifications are blocked. Allow them in your browser site settings to re-enable.
</p>
</template>
<template v-if="authStore.isAdmin">
<div class="field-divider" style="margin: 1rem 0;" />
<p class="section-desc">
If push notifications fail due to a corrupted or misformatted VAPID key, regenerate
the key pair here. All existing subscriptions will be cleared — you will need to
re-enable notifications in this browser afterwards.
</p>
<div class="actions" style="margin-top: 0.5rem;">
<button class="btn-danger" :disabled="vapidResetting" @click="resetVapidKeys">
{{ vapidResetting ? 'Regenerating' : 'Regenerate VAPID Keys' }}
</button>
</div>
<p v-if="vapidResetMsg" :class="vapidResetError ? 'field-error' : 'field-hint'" style="margin-top: 0.5rem;">
{{ vapidResetMsg }}
</p>
</template>
</section>
<section class="settings-section">
@@ -2489,21 +2553,113 @@ function formatUserDate(iso: string): string {
<div class="mcp-install-steps">
<h3>Installation</h3>
<ol>
<div class="mcp-client-tabs" role="tablist">
<button
type="button"
role="tab"
:aria-selected="mcpClientTab === 'claude-code'"
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-code' }]"
@click="mcpClientTab = 'claude-code'"
>Claude Code</button>
<button
type="button"
role="tab"
:aria-selected="mcpClientTab === 'claude-desktop'"
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-desktop' }]"
@click="mcpClientTab = 'claude-desktop'"
>Claude Desktop</button>
<button
type="button"
role="tab"
:aria-selected="mcpClientTab === 'other'"
:class="['mcp-client-tab', { active: mcpClientTab === 'other' }]"
@click="mcpClientTab = 'other'"
>Other</button>
</div>
<!-- Claude Code tab -->
<ol v-if="mcpClientTab === 'claude-code'">
<li>
Download the wheel above and install it:
<pre class="mcp-code">pip install {{ mcpInfo.filename }}</pre>
Download the wheel above and install it with <a href="https://docs.astral.sh/uv/" target="_blank" rel="noopener">uv</a> or <a href="https://pipx.pypa.io/" target="_blank" rel="noopener">pipx</a> — either one works:
<div class="mcp-code-row">
<pre class="mcp-code">uv tool install ./{{ mcpInfo.filename }}</pre>
<button class="btn btn-secondary btn-sm" @click="copySnippet(`uv tool install ./${mcpInfo.filename}`, 'cc-install-uv')">
{{ copiedSnippetKey === 'cc-install-uv' ? 'Copied' : 'Copy' }}
</button>
</div>
<div class="mcp-code-row">
<pre class="mcp-code">pipx install ./{{ mcpInfo.filename }}</pre>
<button class="btn btn-secondary btn-sm" @click="copySnippet(`pipx install ./${mcpInfo.filename}`, 'cc-install-pipx')">
{{ copiedSnippetKey === 'cc-install-pipx' ? 'Copied' : 'Copy' }}
</button>
</div>
<p class="mcp-hint">This puts <code>fable-mcp</code> on your PATH so Claude Code can launch it.</p>
</li>
<li>
Create a <code>.env</code> file (or set environment variables):
<pre class="mcp-code">FABLE_URL={{ origin }}
FABLE_API_KEY=&lt;your-api-key&gt;</pre>
Register the server with Claude Code:
<div class="mcp-code-row">
<pre class="mcp-code">{{ claudeCodeCommand }}</pre>
<button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
{{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
</button>
</div>
<p class="mcp-hint">
<code>--scope user</code> makes <code>fable</code> available across all your projects. Use <code>--scope project</code> to write it into the current repo's <code>.mcp.json</code> instead, or <code>--scope local</code> for this machine and repo only.
<span v-if="!newKeyValue"> Generate an API key above to pre-fill the command.</span>
</p>
</li>
<li>
Add to your Claude MCP config (<code>~/.claude.json</code> or the Claude Desktop config):
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
Verify the connection by running <code>/mcp</code> inside Claude Code — <code>fable</code> should appear as connected.
</li>
</ol>
<!-- Claude Desktop tab -->
<ol v-else-if="mcpClientTab === 'claude-desktop'">
<li>
Download the wheel above and install it with uv or pipx:
<div class="mcp-code-row">
<pre class="mcp-code">uv tool install ./{{ mcpInfo.filename }}</pre>
<button class="btn btn-secondary btn-sm" @click="copySnippet(`uv tool install ./${mcpInfo.filename}`, 'cd-install')">
{{ copiedSnippetKey === 'cd-install' ? 'Copied' : 'Copy' }}
</button>
</div>
</li>
<li>
Add this block to your Claude Desktop MCP config file (<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS, <code>%APPDATA%\Claude\claude_desktop_config.json</code> on Windows):
<div class="mcp-code-row">
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
<button class="btn btn-secondary btn-sm" @click="copySnippet(mcpConfigSnippet, 'cd-config')">
{{ copiedSnippetKey === 'cd-config' ? 'Copied' : 'Copy' }}
</button>
</div>
</li>
<li>
Restart Claude Desktop. The Fable tools should appear in the available tools list.
</li>
</ol>
<!-- Other tab -->
<div v-else class="mcp-other">
<p>
Any MCP-compatible client can launch <code>fable-mcp</code> over stdio. The exact setup depends on the client, but in general you'll need:
</p>
<ul class="mcp-plain-list">
<li>Install the wheel: <code>uv tool install ./{{ mcpInfo.filename }}</code> or <code>pipx install ./{{ mcpInfo.filename }}</code></li>
<li>Command: <code>fable-mcp</code></li>
<li>Transport: <code>stdio</code></li>
<li>Environment variables:
<div class="mcp-code-row">
<pre class="mcp-code">FABLE_URL={{ origin }}
FABLE_API_KEY={{ effectiveApiKey }}</pre>
<button class="btn btn-secondary btn-sm" @click="copySnippet(`FABLE_URL=${origin}\nFABLE_API_KEY=${effectiveApiKey}`, 'other-env')">
{{ copiedSnippetKey === 'other-env' ? 'Copied' : 'Copy' }}
</button>
</div>
</li>
</ul>
<p class="mcp-hint">Consult your MCP client's documentation for how to register a stdio server. These instructions have only been tested with Claude Code.</p>
</div>
</div>
</div>
<div v-else class="mcp-unavailable">
@@ -2984,7 +3140,7 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
}
.sidebar-item.active {
color: var(--color-primary);
background: rgba(99, 102, 241, 0.08);
background: rgba(124, 58, 237, 0.08);
border-left-color: var(--color-primary);
font-weight: 600;
}
@@ -3449,7 +3605,7 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
}
.sidebar-item.active {
border-bottom-color: var(--color-primary);
background: rgba(99, 102, 241, 0.08);
background: var(--color-primary-tint);
}
.settings-grid {
grid-template-columns: 1fr;
@@ -4175,6 +4331,54 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
white-space: pre-wrap;
word-break: break-all;
overflow-x: auto;
flex: 1;
}
.mcp-client-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.mcp-client-tab {
background: transparent;
border: none;
padding: 0.5rem 0.9rem;
font-size: 0.85rem;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.mcp-client-tab:hover { color: var(--color-text); }
.mcp-client-tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
.mcp-code-row {
display: flex;
align-items: stretch;
gap: 0.4rem;
margin-top: 0.4rem;
}
.mcp-code-row .mcp-code { margin-top: 0; }
.mcp-code-row .btn-sm { white-space: nowrap; }
.mcp-hint {
margin-top: 0.5rem;
font-size: 0.8rem;
opacity: 0.7;
line-height: 1.5;
}
.mcp-other p { font-size: 0.9rem; line-height: 1.6; }
.mcp-plain-list {
list-style: disc;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.9rem;
line-height: 1.6;
}
/* Voice tab */
+2 -2
View File
@@ -362,7 +362,7 @@ async function confirmDelete() {
await store.deleteTask(taskId.value);
dirty.value = false;
toast.show("Task deleted");
router.push("/tasks");
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch {
toast.show("Failed to delete task", "error");
}
@@ -410,7 +410,7 @@ useEditorGuards(dirty, save);
<main class="editor-page task-editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/tasks" class="btn-back"> Tasks</router-link>
<router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
+4 -4
View File
@@ -480,23 +480,23 @@ const subTaskProgress = computed(() => {
padding: 0.45rem 1.1rem;
border: none;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-edit:hover {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
box-shadow: var(--glow-cta-hover);
opacity: 0.95;
color: #fff;
}
.btn-advance {
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: var(--radius-sm);
-678
View File
@@ -1,678 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import type { Task, TaskStatus, TaskPriority } from "@/types/task";
import { apiGet } from "@/api/client";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
import TaskCard from "@/components/TaskCard.vue";
import TagPill from "@/components/TagPill.vue";
type ViewMode = "smart" | "grouped";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const searchBarRef = ref<{ focus: () => void } | null>(null);
function onFocusSearch() {
searchBarRef.value?.focus();
}
const _storedMode = localStorage.getItem("fabled-tasks-view-mode");
const viewMode = ref<ViewMode>(_storedMode === "grouped" ? "grouped" : "smart");
function setViewMode(mode: ViewMode) {
viewMode.value = mode;
localStorage.setItem("fabled-tasks-view-mode", mode);
store.limit = mode === "grouped" ? 100 : 200;
store.offset = 0;
store.refresh();
}
// Keyboard nav disabled — smart sections span multiple containers
useListKeyboardNavigation(
computed(() => store.tasks),
(task) => router.push(`/tasks/${task.id}`),
".kb-active-item",
computed(() => false),
);
// Project map for group labels and task breadcrumbs
const projectMap = ref<Map<number, string>>(new Map());
async function loadProjects() {
try {
const data = await apiGet<{ projects: { id: number; title: string }[] }>("/api/projects");
projectMap.value = new Map(data.projects.map((p) => [p.id, p.title]));
} catch {
// non-fatal — labels just won't show
}
}
// Grouped tasks: [{projectId, title, tasks[]}]
interface TaskGroup {
projectId: number | null;
title: string;
tasks: Task[];
}
interface TaskSection {
key: string;
label: string;
tasks: Task[];
}
const smartSections = computed<TaskSection[]>(() => {
if (viewMode.value !== "smart") return [];
const today = new Date().toISOString().slice(0, 10);
const weekEnd = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const overdue: Task[] = [], dueToday: Task[] = [], thisWeek: Task[] = [],
upcoming: Task[] = [], noDueDate: Task[] = [], done: Task[] = [];
for (const task of store.tasks) {
if (task.status === "done") { done.push(task); continue; }
if (!task.due_date) { noDueDate.push(task); continue; }
if (task.due_date < today) { overdue.push(task); continue; }
if (task.due_date === today) { dueToday.push(task); continue; }
if (task.due_date <= weekEnd) { thisWeek.push(task); continue; }
upcoming.push(task);
}
const sections: TaskSection[] = [];
if (overdue.length) sections.push({ key: "overdue", label: "Overdue", tasks: overdue });
if (dueToday.length) sections.push({ key: "today", label: "Due Today", tasks: dueToday });
if (thisWeek.length) sections.push({ key: "week", label: "This Week", tasks: thisWeek });
if (upcoming.length) sections.push({ key: "upcoming", label: "Upcoming", tasks: upcoming });
if (noDueDate.length) sections.push({ key: "no-date", label: "No Due Date", tasks: noDueDate });
if (done.length) sections.push({ key: "done", label: "Completed", tasks: done });
return sections;
});
const groupedTasks = computed<TaskGroup[]>(() => {
if (viewMode.value !== "grouped") return [];
const map = new Map<number | null, Task[]>();
for (const task of store.tasks) {
const key = task.project_id ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(task);
}
const groups: TaskGroup[] = [];
// Named projects first (sorted by title), then "No Project"
for (const [id, tasks] of map) {
if (id !== null) {
groups.push({
projectId: id,
title: projectMap.value.get(id) ?? `Project #${id}`,
tasks,
});
}
}
groups.sort((a, b) => a.title.localeCompare(b.title));
if (map.has(null)) {
groups.push({ projectId: null, title: "No Project", tasks: map.get(null)! });
}
return groups;
});
onMounted(async () => {
const tag = route.query.tag;
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.activeTagFilters = tags;
}
if (route.query.status) {
const qs = route.query.status;
store.statusFilter = (Array.isArray(qs) ? qs : [qs]).filter(Boolean) as TaskStatus[];
}
store.limit = viewMode.value === "grouped" ? 100 : 200;
collapsedGroups.value.add("done");
await Promise.all([store.refresh(), loadProjects()]);
document.addEventListener("shortcut:focus-search", onFocusSearch);
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch);
});
watch(
() => route.query.tag,
(tag) => {
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.activeTagFilters = tags;
store.offset = 0;
store.refresh();
}
}
);
function onSearch(q: string) {
store.setSearch(q);
}
function toggleStatusChip(value: TaskStatus) {
const current = [...store.statusFilter];
const idx = current.indexOf(value);
if (idx === -1) current.push(value);
else current.splice(idx, 1);
store.setStatusFilter(current);
}
function togglePriorityChip(value: TaskPriority) {
const current = [...store.priorityFilter];
const idx = current.indexOf(value);
if (idx === -1) current.push(value);
else current.splice(idx, 1);
store.setPriorityFilter(current);
}
function onTagClick(tag: string) {
store.addTagFilter(tag);
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
}
function onTagDismiss(tag: string) {
store.removeTagFilter(tag);
const newQuery = { ...route.query };
if (store.activeTagFilters.length > 0) {
newQuery.tag = store.activeTagFilters;
} else {
delete newQuery.tag;
}
router.replace({ query: newQuery });
}
function onSortChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setSort(value, store.sortOrder);
}
function toggleOrder() {
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
}
function onStatusToggle(id: number, status: TaskStatus) {
store.patchStatus(id, status);
}
// Collapse state for grouped sections
const collapsedGroups = ref<Set<string>>(new Set());
function toggleGroup(key: string) {
if (collapsedGroups.value.has(key)) {
collapsedGroups.value.delete(key);
} else {
collapsedGroups.value.add(key);
}
}
</script>
<template>
<main class="tasks-list">
<div class="header">
<h1>Tasks</h1>
<router-link to="/tasks/new" class="btn-new">+ New Task</router-link>
</div>
<SearchBar ref="searchBarRef" @search="onSearch" />
<div class="controls">
<div class="filter-controls">
<div class="filter-chip-group">
<button v-for="s in (['todo', 'in_progress', 'done', 'cancelled'] as TaskStatus[])" :key="s"
:class="['filter-chip', { active: store.statusFilter.includes(s) }]"
@click="toggleStatusChip(s)">
{{ { todo: 'Todo', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled' }[s] }}
</button>
</div>
<div class="filter-chip-group">
<button v-for="p in (['low', 'medium', 'high'] as TaskPriority[])" :key="p"
:class="['filter-chip', { active: store.priorityFilter.includes(p) }]"
@click="togglePriorityChip(p)">
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
</button>
</div>
</div>
<div class="right-controls">
<div class="sort-controls">
<select :value="store.sortField" @change="onSortChange" class="sort-select">
<option value="updated_at">Updated</option>
<option value="created_at">Created</option>
<option value="title">Title</option>
<option value="due_date">Due Date</option>
<option value="priority">Priority</option>
</select>
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
{{ store.sortOrder === "asc" ? "↑" : "↓" }}
</button>
</div>
<!-- View mode toggle -->
<div class="view-toggle">
<button
:class="['toggle-btn', { active: viewMode === 'smart' }]"
title="Smart sections (by due date)"
@click="setViewMode('smart')"
></button>
<button
:class="['toggle-btn', { active: viewMode === 'grouped' }]"
title="Group by project"
@click="setViewMode('grouped')"
></button>
</div>
</div>
</div>
<div v-if="store.activeTagFilters.length" class="active-filters">
<span class="filter-label">Filtering by:</span>
<TagPill
v-for="tag in store.activeTagFilters"
:key="tag"
:tag="tag"
dismissible
@dismiss="onTagDismiss"
/>
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<div v-if="store.loading" class="task-list-skeleton">
<div class="skeleton-row" v-for="i in 6" :key="i"></div>
</div>
<div v-else-if="store.tasks.length === 0" class="empty-state">
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter.length || store.priorityFilter.length">
<p class="empty-title">No tasks match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
<template v-else>
<div class="empty-state-rich">
<div class="empty-icon"></div>
<p class="empty-title">No tasks yet</p>
<p class="empty-sub">Create your first task to start tracking work</p>
<router-link to="/tasks/new" class="empty-action">New task </router-link>
</div>
</template>
</div>
<!-- Grouped view -->
<template v-else-if="viewMode === 'grouped'">
<div
v-for="group in groupedTasks"
:key="group.projectId ?? 'none'"
class="task-group"
>
<button
class="group-header"
@click="toggleGroup(String(group.projectId))"
>
<span class="group-chevron">{{ collapsedGroups.has(String(group.projectId)) ? '▶' : '▼' }}</span>
<span class="group-title">{{ group.title }}</span>
<span class="group-count">{{ group.tasks.length }}</span>
<router-link
v-if="group.projectId"
:to="`/projects/${group.projectId}`"
class="group-open-link"
@click.stop
>Open project </router-link>
</button>
<div v-if="!collapsedGroups.has(String(group.projectId))" class="group-tasks">
<TaskCard
v-for="task in group.tasks"
:key="task.id"
:task="task"
compact
@tag-click="onTagClick"
@status-toggle="onStatusToggle"
/>
</div>
</div>
</template>
<!-- Smart sections view (by due date) -->
<template v-else>
<div v-for="section in smartSections" :key="section.key" class="task-section">
<button class="section-header" @click="toggleGroup(section.key)">
<span :class="['section-dot', `dot-${section.key}`]"></span>
<span class="section-label">{{ section.label }}</span>
<span class="section-count">{{ section.tasks.length }}</span>
<span class="section-chevron">{{ collapsedGroups.has(section.key) ? "▶" : "▼" }}</span>
</button>
<div v-show="!collapsedGroups.has(section.key)" class="section-tasks">
<TaskCard
v-for="task in section.tasks"
:key="task.id"
:task="task"
compact
:project-title="task.project_id ? (projectMap.get(task.project_id) ?? undefined) : undefined"
@tag-click="onTagClick"
@status-toggle="onStatusToggle"
/>
</div>
</div>
</template>
</main>
</template>
<style scoped>
.tasks-list {
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h1 {
margin: 0;
}
.btn-new {
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-new:hover {
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-chip-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.filter-chip {
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--color-input-border);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.78rem;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.filter-chip.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.right-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-select,
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.view-toggle {
display: flex;
gap: 0.2rem;
}
.toggle-btn {
padding: 0.3rem 0.55rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.toggle-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.active-filters {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.clear-filters {
background: none;
border: none;
color: var(--color-danger);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
/* Skeleton loading */
.task-list-skeleton {
margin-top: 1rem;
}
.skeleton-row {
height: 44px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
margin-bottom: 0.3rem;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Smart sections */
.task-section {
margin-top: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: none;
border: none;
border-bottom: 1px solid var(--color-border);
padding: 0.3rem 0;
cursor: pointer;
text-align: left;
color: var(--color-text);
}
.section-header:hover .section-label {
color: var(--color-primary);
}
.section-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-overdue { background: var(--color-danger, #e74c3c); }
.dot-today { background: var(--color-primary); }
.dot-week { background: #f59e0b; }
.dot-upcoming { background: var(--color-text-secondary); }
.dot-no-date { background: var(--color-text-muted); }
.dot-done { background: var(--color-status-done, #22c55e); }
.section-label {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 1;
transition: color 0.15s;
}
.section-count {
font-size: 0.75rem;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.section-chevron {
font-size: 0.65rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.section-tasks {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0 0.25rem;
}
/* Grouped view */
.task-group {
margin-top: 1.25rem;
}
.group-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: none;
border: none;
border-bottom: 1px solid var(--color-border);
padding: 0.3rem 0 0.3rem 0;
cursor: pointer;
text-align: left;
color: var(--color-text);
}
.group-header:hover {
color: var(--color-primary);
}
.group-chevron {
font-size: 0.65rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.group-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 1;
}
.group-count {
font-size: 0.75rem;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.group-open-link {
font-size: 0.75rem;
color: var(--color-primary);
text-decoration: none;
flex-shrink: 0;
}
.group-open-link:hover {
text-decoration: underline;
}
.group-tasks {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.4rem;
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
}
.empty-state-rich {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.3;
}
.empty-state-rich .empty-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
margin: 0 0 0.35rem;
}
.empty-sub {
font-size: 0.85rem;
margin: 0 0 1rem;
}
.empty-action {
display: inline-block;
padding: 0.4rem 1rem;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
color: var(--color-primary);
text-decoration: none;
font-size: 0.85rem;
transition: background 0.15s, color 0.15s;
}
.empty-action:hover {
background: var(--color-primary);
color: #fff;
}
.kb-active-item {
outline: 2px solid var(--color-primary);
border-radius: var(--radius-md);
}
</style>
+6 -4
View File
@@ -75,8 +75,11 @@ watch(
noteEditorRef.value?.reload();
}
if (
["create_task", "update_task", "create_milestone", "update_milestone"].includes(tc.function) &&
tc.status === "success"
tc.status === "success" && (
["create_milestone", "update_milestone"].includes(tc.function) ||
(tc.function === "create_note" && tc.result?.data?.status) ||
(tc.function === "update_note" && tc.result?.data?.status !== undefined)
)
) {
taskPanelRef.value?.reload();
}
@@ -135,8 +138,7 @@ onMounted(async () => {
if (workspaceConvId === null) {
// Create a new workspace conversation and persist its ID
const title = project.value ? `${project.value.title} — Workspace` : "Workspace";
const conv = await chatStore.createConversation(title);
const conv = await chatStore.createConversation();
workspaceConvId = conv.id;
isNewConv = true;
await chatStore.fetchConversation(conv.id);
+31 -1
View File
@@ -1,9 +1,39 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { viteStaticCopy } from "vite-plugin-static-copy";
import { resolve } from "path";
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
// @ricky0123/vad-web ships ONNX + audio-worklet assets and depends on
// onnxruntime-web's WASM binaries. Copy them into the served root so
// MicVAD can load them at runtime via `baseAssetPath: "/"`.
viteStaticCopy({
targets: [
{
src: "node_modules/@ricky0123/vad-web/dist/*.onnx",
dest: "",
rename: { stripBase: true },
},
{
src: "node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js",
dest: "",
rename: { stripBase: true },
},
{
src: "node_modules/onnxruntime-web/dist/*.wasm",
dest: "",
rename: { stripBase: true },
},
{
src: "node_modules/onnxruntime-web/dist/*.mjs",
dest: "",
rename: { stripBase: true },
},
],
}),
],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
+24 -11
View File
@@ -1,26 +1,28 @@
# Runner base image for Forgejo act_runner job containers.
# Pre-installs Python 3.12 and Node 20 so the test job skips the
# ~3-4 minute deadsnakes PPA install on every run.
# Pre-installs Python 3.12, Node 22, uv, and ruff so workflows skip
# lengthy runtime installs on every run.
#
# Build and push (one-time setup, re-run if this file changes):
# Build and push (re-run when this file changes):
# docker build -f infra/Dockerfile.runner-base \
# -t git.fabledsword.com/bvandeusen/runner-base:py3.12-node22 .
# docker push git.fabledsword.com/bvandeusen/runner-base:py3.12-node22
# -t git.fabledsword.com/bvandeusen/runner-base:ci-runner .
# docker push git.fabledsword.com/bvandeusen/runner-base:ci-runner
#
# Then redeploy the act_runner stack in Portainer to pick up the new label.
# Then redeploy the act_runner stack in Portainer to pick up the new image.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=UTC
# Split into separate RUN steps so each pushed layer is smaller.
# One combined layer would be ~280MB and exceeds the nginx upload timeout.
# Split into separate RUN steps so each pushed layer is smaller — one
# combined layer exceeds the nginx upload timeout on the self-hosted
# Forgejo registry.
# Python 3.12 ships in Ubuntu 24.04 main repos — no PPA needed.
# Python 3.12 (ships in Ubuntu 24.04 main repos) + core tools
RUN apt-get update -qq && \
apt-get install -y -qq \
python3.12 python3.12-dev python3.12-venv python3-pip \
git curl ca-certificates gnupg && \
python3.12 python3.12-dev python3.12-venv \
git curl ca-certificates gnupg jq tzdata && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Node 22 LTS via NodeSource
@@ -33,3 +35,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
RUN apt-get update -qq && \
apt-get install -y -qq docker.io && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# uv — fast Python package installer/resolver (~10x faster than pip).
# Used by test jobs instead of pip for venv creation + dep installs.
RUN curl -LsSf https://astral.sh/uv/install.sh | env INSTALLER_NO_MODIFY_PATH=1 sh && \
mv /root/.local/bin/uv /usr/local/bin/ && \
mv /root/.local/bin/uvx /usr/local/bin/
# ruff — Python linter. Baked in so the lint job is just
# `ruff check src/` with zero install overhead.
RUN curl -LsSf https://astral.sh/ruff/install.sh | env INSTALLER_NO_MODIFY_PATH=1 sh && \
mv /root/.local/bin/ruff /usr/local/bin/
+8 -4
View File
@@ -169,11 +169,12 @@ def create_app() -> Quart:
async def _warm_model(model: str) -> None:
"""Warm an already-installed model into VRAM (no pull)."""
from fabledassistant.services.llm import keep_alive_for
try:
async with httpx.AsyncClient(timeout=300.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/generate",
json={"model": model, "prompt": "", "keep_alive": "2h"},
json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)},
)
logger.info("Warmed model '%s' into VRAM", model)
except Exception:
@@ -187,14 +188,17 @@ def create_app() -> Quart:
The num_ctx must match what real requests will use so Ollama doesn't reload.
"""
try:
from fabledassistant.services.llm import build_context, pick_num_ctx
from fabledassistant.services.llm import build_context, keep_alive_for, pick_num_ctx
from fabledassistant.services.tools import get_tools_for_user
messages, _ = await build_context(
user_id=user_id,
history=[],
current_note_id=None,
user_message=" ",
)
num_ctx = pick_num_ctx(messages)
# Include tool schemas so num_ctx matches real chat requests.
tools = await get_tools_for_user(user_id)
num_ctx = pick_num_ctx(messages, tools=tools)
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/chat",
@@ -203,7 +207,7 @@ def create_app() -> Quart:
"messages": messages,
"stream": False,
"options": {"num_predict": 1, "num_ctx": num_ctx},
"keep_alive": "2h",
"keep_alive": keep_alive_for(model),
},
)
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
+7 -1
View File
@@ -27,7 +27,13 @@ class Config:
# 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")
OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "gemma3:4b")
# Ollama keep_alive — how long a model stays resident in VRAM after its last
# request. Main model gets a longer window since it's used interactively;
# the background model is called sporadically and doesn't need to camp VRAM.
# Format matches Ollama's duration strings: "30m", "10m", "1h", "0s", "-1" (forever).
OLLAMA_KEEP_ALIVE_MAIN: str = os.environ.get("OLLAMA_KEEP_ALIVE_MAIN", "30m")
OLLAMA_KEEP_ALIVE_BACKGROUND: str = os.environ.get("OLLAMA_KEEP_ALIVE_BACKGROUND", "10m")
# KV cache context window for generation. Keep this as small as practical —
# a larger context forces more KV cache into CPU RAM, drastically slowing prefill.
# 16384 covers ~30+ message conversations with our system prompt comfortably.
+19 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import ARRAY, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy import ARRAY, BigInteger, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
@@ -46,6 +46,17 @@ class RssItem(Base):
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Truncated to 2000 chars to keep DB size reasonable
content: Mapped[str] = mapped_column(Text, default="")
# Full trafilatura-extracted article body, populated lazily on first
# discuss-click / enrichment pass. Nullable — most items never get this
# cached. Expires naturally with the item (90-day retention).
content_full: Mapped[str | None] = mapped_column(Text, nullable=True)
# Map-reduced conversation-ready context derived from content_full. See
# services/article_context.py — populated on first discuss click so
# repeat clicks skip both the fetch and the LLM map step.
context_prepared: Mapped[str | None] = mapped_column(Text, nullable=True)
content_fetched_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
@@ -55,6 +66,13 @@ class RssItem(Base):
classified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Note persisting the first-click discussion summary. Set by the article
# discussion pipeline once the seeded chat completes its first assistant
# reply; links back into RAG so re-discussing the same article lands the
# prior summary in context.
discussion_note_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
feed: Mapped["RssFeed"] = relationship(back_populates="items")
+196 -21
View File
@@ -155,6 +155,44 @@ async def get_weather():
return jsonify({"locations": cards, "temp_unit": temp_unit})
@briefing_bp.route("/weather/current", methods=["GET"])
@_REQUIRE
async def get_current_weather():
"""Return current temperature, conditions, and precipitation for the user's primary location.
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
"""
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
locations = config.get("locations", {})
except Exception:
return jsonify({"error": "No briefing config"}), 404
# Use home location, fall back to work
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
from fabledassistant.services.weather import fetch_current_conditions
current = await fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
# Convert temperature if needed
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@briefing_bp.route("/weather/geocode", methods=["POST"])
@_REQUIRE
async def geocode_location():
@@ -270,14 +308,55 @@ async def manual_trigger():
logger.warning("Pre-trigger refresh failed for user %d", g.user.id, exc_info=True)
from fabledassistant.services.briefing_pipeline import run_compilation
from fabledassistant.services.briefing_scheduler import _persist_agentic_messages
model = await get_setting(g.user.id, "default_model", "")
conv = await get_or_create_today_conversation(g.user.id, model)
text, metadata = await run_compilation(g.user.id, slot, model)
msg = await post_message(conv.id, "assistant", text, metadata=metadata)
await _persist_agentic_messages(conv.id, slot, metadata)
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
final_meta["briefing_slot"] = slot
msg = await post_message(conv.id, "assistant", text, metadata=final_meta)
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
@briefing_bp.route("/reset-today", methods=["POST"])
@_REQUIRE
async def reset_today_briefing():
"""Delete all messages in today's briefing conversation.
The conversation row itself is kept so its id stays stable for any
open UI sessions. Intended for "wipe and start fresh" workflows
driven from the MCP when iterating on prompts. Pair with
``POST /api/briefing/trigger`` to immediately regenerate.
"""
from sqlalchemy import delete as _delete
from fabledassistant.services.tz import user_briefing_date
# User-local briefing day (flips at 4am local), not ``date.today()`` —
# see services/tz.py for rationale.
today = await user_briefing_date(g.user.id)
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == g.user.id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == today,
)
)
conv = result.scalars().first()
if conv is None:
return jsonify({"deleted": 0, "conversation_id": None})
deleted_result = await session.execute(
_delete(Message).where(Message.conversation_id == conv.id)
)
await session.commit()
deleted = deleted_result.rowcount or 0
return jsonify({"deleted": deleted, "conversation_id": conv.id})
# ── RSS Reactions ──────────────────────────────────────────────────────────────
@briefing_bp.route("/rss-reactions", methods=["POST"])
@@ -453,29 +532,122 @@ async def discuss_article(item_id: int):
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
article_content = item.content or ""
# Shared helper handles the three-layer cache (context_prepared →
# content_full → fresh fetch), writes the synthetic read_article tool
# exchange and the conversational seed user prompt into the conversation.
# The /news from-article route calls the same helper so behavior stays
# byte-identical across entry points.
from fabledassistant.services.article_context import (
EmptyArticleError,
seed_article_discussion,
)
# 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.")
model = await get_setting(uid, "default_model", "") or ""
try:
discuss_prompt = await seed_article_discussion(conv_id, item, model)
except EmptyArticleError as e:
return jsonify({"error": str(e)}), 422
# Reload conversation with fresh messages to build history
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
discuss_prompt,
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_topic(topic: str):
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Find recent articles with this topic (last 2 days)
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssFeed.user_id == uid)
.where(RssItem.topics.contains([topic]))
.order_by(RssItem.published_at.desc().nullslast())
.limit(5)
)
items = list(result.scalars().all())
if not items:
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
# Fetch full content for each article
from fabledassistant.services.rss import _fetch_full_article
synthetic_tool_calls = []
for item in items:
content = await _fetch_full_article(item.url) if item.url else None
content = content or item.content or ""
synthetic_tool_calls.append({
"function": "read_article",
"arguments": {"url": item.url or ""},
"result": {
"success": True,
"type": "article_content",
"url": item.url or "",
"title": item.title or "",
"source": "",
"content": content[:8000], # cap per article to stay within context
"truncated": len(content) > 8000,
},
})
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
user_prompt = (
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
"Don't just summarize each one — draw connections between the sources, "
"highlight where they agree or disagree, and share your analysis of what "
"this means. Let's have a real discussion about this topic."
)
await add_message(conv_id, "user", user_prompt)
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
@@ -500,8 +672,11 @@ async def discuss_article(item_id: int):
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
"Please summarize and discuss this article.",
think=True,
user_prompt,
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
"article_count": len(items),
}), 202
+58 -25
View File
@@ -350,11 +350,12 @@ async def warm_model_route():
return jsonify({"error": "model is required"}), 400
async def _warm():
from fabledassistant.services.llm import keep_alive_for
try:
async with httpx.AsyncClient(timeout=300.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/generate",
json={"model": model, "prompt": "", "keep_alive": "30m"},
json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)},
)
logger.info("Warmed model %s", model)
except Exception as e:
@@ -509,46 +510,78 @@ async def delete_model_route():
@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."""
"""Create a chat conversation seeded for article discussion and auto-run.
Mirrors the briefing ``discuss_article`` route: creates a fresh
conversation, stages the shared synthetic read_article exchange + seed
prompt, then kicks off generation so the client lands on an in-flight
stream. The Flutter and web chat screens reconnect to the running buffer
on mount.
"""
from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Message
from fabledassistant.models.rss_feed import RssItem, RssFeed
from fabledassistant.services.article_context import (
EmptyArticleError,
seed_article_discussion,
)
uid = get_current_user_id()
async with _async_session() as session:
result = await session.execute(
_select(RssItem, RssFeed.title.label("feed_title"))
_select(RssItem)
.join(RssFeed, RssItem.feed_id == RssFeed.id)
.where(RssItem.id == item_id, RssFeed.user_id == uid)
)
row = result.first()
item = result.scalars().first()
if row is None:
if item 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")
source = feed_title or "News"
content_body = (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}"
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
try:
discuss_prompt = await seed_article_discussion(conv.id, item, model)
except EmptyArticleError as e:
# Roll back the empty conversation so the user doesn't end up with a
# phantom entry in their chat list.
try:
await delete_conversation(uid, conv.id)
except Exception:
logger.warning("Failed to clean up empty article conversation %s", conv.id)
return jsonify({"error": str(e)}), 422
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()
# Reload conversation so we see the two messages the helper just added.
conv = await get_conversation(uid, conv.id)
assert conv is not None
return jsonify({"conversation_id": conv.id}), 201
history: list[dict] = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict: dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
assistant_msg = await add_message(conv.id, "assistant", "", status="generating")
buf = create_buffer(conv.id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model, uid, conv.id, conv.title or "", discuss_prompt,
))
return jsonify({
"conversation_id": conv.id,
"assistant_message_id": assistant_msg.id,
"status": "generating",
}), 202
+1 -1
View File
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
_VALID_TYPES = {"note", "person", "place", "list"}
_VALID_TYPES = {"note", "person", "place", "list", "task"}
_VALID_SORTS = {"modified", "created", "alpha", "type"}
+3 -3
View File
@@ -52,7 +52,7 @@ async def create_project_route():
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
status = data.get("status", "active")
if status not in ("active", "completed", "archived"):
if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await create_project(
uid,
@@ -88,8 +88,8 @@ async def update_project_route(project_id: int):
uid = get_current_user_id()
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"):
fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "paused", "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:
+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
+3 -5
View File
@@ -11,7 +11,6 @@ from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.config import Config
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
@@ -21,7 +20,7 @@ quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-c
# 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"}
_CAPTURE_TOOL_NAMES = {"create_note", "create_event", "update_note", "research_topic"}
_SYSTEM_PROMPT = """\
Today is {today}. You are a quick-capture assistant. The user has sent a short \
@@ -53,11 +52,10 @@ async def quick_capture_route():
{"role": "user", "content": text},
]
think = _should_think(text, think_requested=True)
# Quick capture is a fast classification path — never think.
tool_calls: list[dict] = []
try:
async for chunk in stream_chat_with_tools(messages, model, tools=capture_tools, think=think, num_ctx=4096):
async for chunk in stream_chat_with_tools(messages, model, tools=capture_tools, think=False, num_ctx=4096):
if chunk.type == "tool_calls" and chunk.tool_calls:
tool_calls = chunk.tool_calls
except Exception:
+17 -3
View File
@@ -16,6 +16,7 @@ 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
from fabledassistant.services.tools import get_tools_for_user
try:
messages, _ = await build_context(
user_id=user_id,
@@ -23,7 +24,12 @@ async def _prime_kv_cache_bg(user_id: int, model: str) -> None:
current_note_id=None,
user_message=" ",
)
num_ctx = pick_num_ctx(messages)
# Size the prime to match what real chat requests will use, including
# tool schemas — otherwise Ollama reloads the model on the first real
# request and throws away the cache we just built.
tools = await get_tools_for_user(user_id)
num_ctx = pick_num_ctx(messages, tools=tools)
from fabledassistant.services.llm import keep_alive_for
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/chat",
@@ -32,7 +38,7 @@ async def _prime_kv_cache_bg(user_id: int, model: str) -> None:
"messages": messages,
"stream": False,
"options": {"num_predict": 1, "num_ctx": num_ctx},
"keep_alive": "2h",
"keep_alive": keep_alive_for(model),
},
)
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
@@ -58,6 +64,15 @@ async def update_settings_route():
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
# Normalize model names to lowercase before validation. Ollama's /api/tags
# preserves whatever casing was used at pull time, but /api/chat rejects
# mixed-case tags — lowercasing here keeps the two paths consistent and
# means the stored setting is always in a form Ollama will actually accept.
_MODEL_KEYS = frozenset({"default_model", "background_model"})
for _key in _MODEL_KEYS:
if _key in data and data[_key]:
data[_key] = str(data[_key]).lower()
if "default_model" in data:
installed = await get_installed_models()
if data["default_model"]:
@@ -68,7 +83,6 @@ async def update_settings_route():
# 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", "background_model"})
to_save = {}
for k, v in data.items():
str_v = str(v)
+3 -1
View File
@@ -80,10 +80,12 @@ async def transcribe_audio():
return jsonify({"error": "Audio file too large (max 25 MB)"}), 413
mime_type = audio_file.content_type or "audio/webm"
form = await request.form
context = (form.get("context") or "").strip() or None
t0 = time.monotonic()
try:
transcript = await transcribe(audio_bytes, mime_type)
transcript = await transcribe(audio_bytes, mime_type, initial_prompt=context)
except Exception:
logger.exception("STT transcription failed")
return jsonify({"error": "Transcription failed"}), 500
@@ -0,0 +1,270 @@
"""Prepare article bodies as conversation-ready context.
Used by the briefing ``discuss-article`` flow and the ``/news`` discuss button.
A raw trafilatura extraction is often too large to drop whole into a chat
history without eating the context window, so this module runs a map-reduce
step over oversized articles and returns a compact, structured context that
still preserves the article's meaning across sections.
Small articles pass through unchanged — map-reduce only fires when the raw
body exceeds CHAR_BUDGET. The output is cached on ``rss_items.context_prepared``
by the caller, so repeat discuss-clicks on the same article skip this work
entirely.
The module also owns ``seed_article_discussion``, the shared routine that
stages a synthetic ``read_article`` tool exchange plus a conversational seed
prompt into a conversation. Both the briefing and ``/news`` entry points call
it so the two flows stay byte-identical — the only thing that differs between
them is whether the conversation already existed or was freshly created.
"""
from __future__ import annotations
import asyncio
import logging
import re
from fabledassistant.models import async_session
from fabledassistant.models.rss_feed import RssItem
from fabledassistant.services.chat import add_message
from fabledassistant.services.llm import generate_completion
logger = logging.getLogger(__name__)
# ~12k tokens at 4 chars/token. Comfortably under OLLAMA_NUM_CTX=16384
# with room left for system prompt, chat history, and the assistant reply.
CHAR_BUDGET = 48_000
# Chunk size for the map step on oversized articles. Overlap preserves
# context across paragraph boundaries that happen to land mid-sentence.
CHUNK_CHARS = 8_000
CHUNK_OVERLAP = 400
_PARA_SPLIT = re.compile(r"\n\s*\n")
def _chunk_by_paragraph(body: str) -> list[str]:
"""Split ``body`` into chunks of up to CHUNK_CHARS, respecting paragraphs.
Paragraphs longer than CHUNK_CHARS are split mid-paragraph as a last
resort. Adjacent chunks share CHUNK_OVERLAP chars of trailing text so
a sentence straddling the boundary stays readable on both sides.
"""
paragraphs = [p.strip() for p in _PARA_SPLIT.split(body) if p.strip()]
chunks: list[str] = []
current: list[str] = []
current_len = 0
for para in paragraphs:
para_len = len(para)
if para_len > CHUNK_CHARS:
if current:
chunks.append("\n\n".join(current))
current, current_len = [], 0
for i in range(0, para_len, CHUNK_CHARS - CHUNK_OVERLAP):
chunks.append(para[i : i + CHUNK_CHARS])
continue
if current_len + para_len + 2 > CHUNK_CHARS and current:
chunks.append("\n\n".join(current))
tail = current[-1][-CHUNK_OVERLAP:] if current else ""
current = [tail, para] if tail else [para]
current_len = len(tail) + para_len + (2 if tail else 0)
else:
current.append(para)
current_len += para_len + 2
if current:
chunks.append("\n\n".join(current))
return chunks
async def _summarize_chunk(title: str, chunk: str, index: int, total: int, model: str) -> str:
"""Map-step summary of one article chunk.
Aims for ~300 words of dense, factual prose — not bullet points — so the
downstream chat model can quote from it naturally.
"""
messages = [
{
"role": "system",
"content": (
"You are summarizing one section of a larger article so a downstream "
"conversation model can discuss the full article without having to read "
"every word.\n\n"
"Requirements:\n"
"- 250350 words of dense factual prose\n"
"- Preserve specific claims, numbers, names, and quotes\n"
"- Do NOT editorialize or add analysis\n"
"- Do NOT use bullet points or headings\n"
"- Do NOT say 'this section' or 'this article' — write content, not meta"
),
},
{
"role": "user",
"content": (
f"Article: {title}\n"
f"Section {index + 1} of {total}:\n\n{chunk}"
),
},
]
try:
# Pin num_ctx — same rationale as services/research.py:66. A large
# chunk plus system prompt can push well past the default window;
# silent truncation here would drop the tail of the chunk without
# any error, producing a misleading summary.
raw = await generate_completion(
messages, model, max_tokens=600, num_ctx=16384
)
return raw.strip()
except Exception:
logger.warning(
"Article chunk summary failed for section %d/%d of '%s'",
index + 1, total, title, exc_info=True,
)
# Fall back to the raw chunk truncated to ~1500 chars so the overall
# pipeline still delivers something rather than dropping the section.
return chunk[:1500]
async def prepare_article_context(
title: str,
url: str,
body: str,
model: str,
) -> str:
"""Return a conversation-ready context block for ``body``.
- Small article (≤ CHAR_BUDGET): returns ``body`` unchanged.
- Oversized article: runs a parallel map step over paragraph-aware
chunks and concatenates the summaries under section headers.
The returned string is what should go into the ``read_article`` synthetic
tool-result in chat history. Callers are responsible for caching it to
``rss_items.context_prepared``.
"""
body = body or ""
if len(body) <= CHAR_BUDGET:
return body
chunks = _chunk_by_paragraph(body)
logger.info(
"Article '%s' is %d chars, map-reducing into %d chunks",
title, len(body), len(chunks),
)
summaries = await asyncio.gather(
*[
_summarize_chunk(title, chunk, i, len(chunks), model)
for i, chunk in enumerate(chunks)
]
)
header = (
f"(This article was longer than the chat window could hold verbatim, "
f"so the full text was split into {len(chunks)} sections and each was "
"summarized below. Each section preserves specific claims, numbers, "
"and quotes from the original.)\n\n"
)
parts = [
f"## Section {i + 1}\n\n{summary}"
for i, summary in enumerate(summaries)
]
return header + "\n\n".join(parts)
# Conversational seed prompt for article discussions. Kept here so both the
# briefing and /news entry points use the exact same wording. See
# feedback_discuss_prompt_style memory: numbered checklists produce
# assignment-completion responses; this conversational seed opens a dialogue.
ARTICLE_DISCUSS_SEED = (
"I want to talk about this article. Start with a substantive summary "
"of what it's arguing and the key evidence it uses, then tell me what "
"stood out to you or seems worth pushing back on. I'll ask follow-ups "
"from there."
)
class EmptyArticleError(Exception):
"""Raised when an article has no extractable body text.
Callers (the briefing and /news discuss routes) map this to a 422 so the
user sees a clear error instead of a hallucinated summary built from an
empty synthetic tool result.
"""
async def seed_article_discussion(
conv_id: int,
item: RssItem,
model: str,
) -> str:
"""Stage the synthetic read_article tool exchange + conversational seed.
Used by both the briefing ``discuss_article`` route and the ``/news``
``from-article`` conversation creator. Handles the three-layer cache
(``context_prepared`` → ``content_full`` → fresh fetch) and inserts two
messages into ``conv_id``:
1. An assistant message with a synthetic ``read_article`` tool_call whose
``result.content`` carries the prepared article context. The message
also carries ``msg_metadata={"rss_item_id": ...}`` so the post-generation
hook in ``generation_task.py`` can locate it and persist the first
reply as a discussion-summary Note.
2. A user message with the shared conversational seed prompt.
Returns the seed prompt string so callers can pass it to ``run_generation``
as ``user_content``.
"""
# Avoid circulars: rss helper imports article_context indirectly nowhere,
# but keep this local for symmetry with the route-level imports it
# replaces.
from fabledassistant.services.rss import get_or_fetch_full_article
if item.context_prepared:
article_content = item.context_prepared
else:
raw_body = await get_or_fetch_full_article(item) or item.content or ""
if not raw_body.strip():
# Hard-fail rather than stage an empty synthetic tool result.
# An empty `content` field silently tells the model "the article
# has nothing in it" and it confabulates from RAG/history. Better
# to surface a clean error to the user.
logger.warning(
"Article discussion aborted: empty body for rss_item %s (%s)",
item.id, item.url,
)
raise EmptyArticleError(
"Couldn't extract any readable text from this article."
)
article_content = await prepare_article_context(
item.title or "", item.url, raw_body, model,
)
if not article_content.strip():
raise EmptyArticleError(
"Couldn't extract any readable text from this article."
)
async with async_session() as session:
fresh = await session.get(RssItem, item.id)
if fresh is not None:
fresh.context_prepared = article_content
await session.commit()
synthetic_tool_calls = [{
"function": "read_article",
"arguments": {"url": item.url},
"result": {
"success": True,
"type": "article_content",
"url": item.url,
"content": article_content,
"truncated": False,
},
}]
await add_message(
conv_id,
"assistant",
"",
status="complete",
tool_calls=synthetic_tool_calls,
msg_metadata={"rss_item_id": item.id, "article_seed": True},
)
await add_message(conv_id, "user", ARTICLE_DISCUSS_SEED)
return ARTICLE_DISCUSS_SEED
@@ -1,12 +1,13 @@
"""Create and manage briefing conversations."""
import logging
from datetime import date, datetime, timezone
from datetime import datetime, timezone
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.services.tz import user_briefing_date
logger = logging.getLogger(__name__)
@@ -15,7 +16,10 @@ async def get_or_create_today_conversation(user_id: int, model: str) -> Conversa
"""
Return today's briefing conversation, creating it if it doesn't exist.
"""
today = date.today()
# "Today" is the user-local briefing day (flips at 4am local), not
# ``date.today()`` — in a UTC container the latter rolls over at
# 19:00 NY local and makes the in-progress briefing disappear.
today = await user_briefing_date(user_id)
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
@@ -46,8 +50,15 @@ async def post_message(
role: str,
content: str,
metadata: dict | None = None,
tool_calls: list | None = None,
) -> Message:
"""Append a message to a briefing conversation."""
"""Append a message to a briefing conversation.
``tool_calls`` is accepted on assistant-role messages so the full
agentic briefing sequence (assistant tool-call turns and tool-role
results) can be persisted as real conversation rows, keeping the
receipts in context on chat follow-ups.
"""
async with async_session() as session:
msg = Message(
conversation_id=conversation_id,
@@ -55,6 +66,7 @@ async def post_message(
content=content,
status="complete",
msg_metadata=metadata,
tool_calls=tool_calls,
)
session.add(msg)
# Bump conversation updated_at
+285 -344
View File
@@ -1,19 +1,14 @@
"""
Briefing pipeline: parallel data gather + two-lane LLM synthesis.
Briefing pipeline: agentic tool-use loop + UI metadata gather.
Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (4pm)
"""
import asyncio
import hashlib
import logging
from datetime import datetime, timezone
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import httpx
from fabledassistant.models import async_session
from fabledassistant.config import Config
from fabledassistant.services.settings import get_setting
@@ -22,201 +17,6 @@ logger = logging.getLogger(__name__)
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
def format_task(task: dict) -> str:
parts = [task.get("title", "Untitled")]
if task.get("status"):
parts.append(f"[{task['status'].replace('_', ' ').title()}]")
if task.get("due_date"):
parts.append(f"due {task['due_date']}")
if task.get("priority") and task["priority"] not in ("none", ""):
parts.append(f"priority: {task['priority']}")
return "".join(parts)
def compute_task_hash(task: dict) -> str:
"""Stable SHA-256 of the task's key change-detectable fields."""
key = "|".join([
str(task.get("status") or ""),
str(task.get("priority") or ""),
str(task.get("due_date") or ""),
str(task.get("title") or ""),
])
return hashlib.sha256(key.encode()).hexdigest()
async def split_changed_tasks(
user_id: int,
tasks: list[dict],
) -> tuple[list[dict], int]:
"""
Compare tasks against the briefing_task_snapshot table.
Returns (changed_tasks, unchanged_count).
changed_tasks includes new tasks (no snapshot row) and tasks whose hash differs.
"""
from sqlalchemy import text
if not tasks:
return [], 0
task_ids = [t["task_id"] for t in tasks if t.get("task_id")]
async with async_session() as session:
result = await session.execute(
text("""
SELECT task_id, snapshot_hash
FROM briefing_task_snapshot
WHERE user_id = :uid AND task_id = ANY(:ids)
""").bindparams(uid=user_id, ids=task_ids)
)
snapshots = {row.task_id: row.snapshot_hash for row in result}
changed = []
unchanged_count = 0
for task in tasks:
current_hash = compute_task_hash(task)
stored_hash = snapshots.get(task.get("task_id"))
if stored_hash is None or stored_hash != current_hash:
changed.append(task)
else:
unchanged_count += 1
return changed, unchanged_count
async def upsert_task_snapshots(user_id: int, tasks: list[dict]) -> None:
"""Upsert snapshot hashes for all tasks included in this briefing."""
from sqlalchemy import text
if not tasks:
return
now = datetime.now(timezone.utc)
async with async_session() as session:
for task in tasks:
task_id = task.get("task_id")
if not task_id:
continue
await session.execute(
text("""
INSERT INTO briefing_task_snapshot (user_id, task_id, snapshot_hash, last_briefed)
VALUES (:uid, :tid, :hash, :now)
ON CONFLICT (user_id, task_id)
DO UPDATE SET snapshot_hash = EXCLUDED.snapshot_hash,
last_briefed = EXCLUDED.last_briefed
""").bindparams(
uid=user_id,
tid=task_id,
hash=compute_task_hash(task),
now=now,
)
)
await session.commit()
# ── Internal data gather ──────────────────────────────────────────────────────
async def _gather_internal(user_id: int) -> dict:
"""Collect tasks, calendar events, and project data."""
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects
from fabledassistant.services.caldav import is_caldav_configured, list_events
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
user_tz = ZoneInfo("UTC")
today = datetime.now(user_tz).date().isoformat()
# Tasks: overdue, due today, high priority in-progress
all_tasks: list[dict] = []
try:
all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100)
all_tasks = [
{
"task_id": t.id,
"title": t.title,
"status": t.status,
"due_date": t.due_date.isoformat() if t.due_date else None,
"priority": t.priority,
}
for t in all_task_objs
]
overdue = [
format_task(t) for t in all_tasks
if t.get("due_date") and t["due_date"] < today and t.get("status") != "done"
]
due_today = [
format_task(t) for t in all_tasks
if t.get("due_date") == today and t.get("status") != "done"
]
high_priority = [
format_task(t) for t in all_tasks
if t.get("priority") == "high" and t.get("status") not in ("done",) and
format_task(t) not in overdue and format_task(t) not in due_today
][:5]
except Exception:
logger.warning("Failed to gather tasks for briefing", exc_info=True)
overdue, due_today, high_priority = [], [], []
# Calendar events today — internal store
calendar_events: list[str] = []
try:
from fabledassistant.services.events import list_events as list_internal_events
today_date = datetime.now(user_tz).date()
day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0, tzinfo=user_tz)
day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59, tzinfo=user_tz)
internal_events = await list_internal_events(
user_id=user_id, date_from=day_start, date_to=day_end
)
for e in internal_events:
if e.get("all_day"):
time_str = "all day"
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.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)
try:
if await is_caldav_configured(user_id):
caldav_evs = await list_events(user_id, start=today, end=today)
for e in (caldav_evs or []):
summary = f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
if summary not in calendar_events:
calendar_events.append(summary)
except Exception:
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
# Projects: active projects
projects_summary = []
try:
projects = await list_projects(user_id)
for p in projects[:5]:
projects_summary.append(p.title or "Untitled project")
except Exception:
logger.warning("Failed to gather projects for briefing", exc_info=True)
return {
"date": today,
"overdue_tasks": overdue,
"due_today": due_today,
"high_priority": high_priority,
"calendar_events": calendar_events,
"active_projects": projects_summary,
"all_tasks_raw": all_tasks,
}
# ── External data gather ──────────────────────────────────────────────────────
async def _gather_external(user_id: int) -> dict:
@@ -235,107 +35,272 @@ async def _gather_external(user_id: int) -> dict:
}
# ── LLM synthesis ─────────────────────────────────────────────────────────────
# ── Agentic briefing (tool-use loop) ──────────────────────────────────────────
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,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"stream": False,
"options": {"num_ctx": num_ctx, "temperature": 0.4},
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload)
resp.raise_for_status()
data = resp.json()
return data.get("message", {}).get("content", "").strip()
except Exception:
logger.warning("LLM synthesis failed", exc_info=True)
return ""
_BRIEFING_AGENT_MAX_ROUNDS = 8
_BRIEFING_AGENT_NUM_CTX = 8192
def _unified_system_prompt(profile_body: str) -> str:
return (
"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 _agentic_system_prompt(
profile_body: str,
slot: str,
today_iso: str,
tz_name: str,
day_from_iso: str,
day_to_iso: str,
) -> str:
"""System prompt for the agentic briefing path.
Pushes the model to ground every factual claim in a tool result and
to be honest when tools return nothing, rather than fabricating
content to fill the narrative.
Pre-computes today's window in the user's local timezone so the
model can call date-sensitive tools (list_events, list_tasks filters)
without having to do any timezone math itself — eliminating a whole
class of "wrong day" bugs.
"""
tz_block = (
f"Today is {today_iso} ({tz_name}). "
f"When calling list_events for today, use:\n"
f" date_from = {day_from_iso}\n"
f" date_to = {day_to_iso}\n"
f"These are already the correct local-day boundaries — do not convert "
f"them to UTC or any other timezone. For other date ranges, compute in "
f"the same timezone.\n\n"
)
if slot == "compilation":
base = (
"You are the user's personal assistant giving their full morning briefing. "
"Weave real data from tool calls into a warm, natural-sounding summary.\n\n"
"Tools to call every compilation (skip only if you already know a category is empty):\n"
"- list_tasks — what's due today, overdue, or in progress\n"
"- list_events — what's on the calendar today\n"
"- get_weather — today's forecast\n"
"- get_rss_items — recent news/blog items from the user's feeds\n"
"- list_projects (optional) — active project context for narrative continuity\n\n"
"Rules:\n"
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
"- If a tool returns nothing (no events today, no overdue tasks, no news items), "
"say so honestly. Don't fabricate items to fill space.\n"
"- For news, pick one or two items worth mentioning — surface the theme, not a laundry list.\n"
"- Write flowing prose. No markdown, no headers, no bullet points.\n"
"- Aim for 6 to 10 sentences. Skip topics that have nothing interesting.\n"
"- Close on one or two concrete, actionable suggestions.\n\n"
)
elif slot == "weekly_review":
base = (
"You are the user's personal assistant delivering a weekly review. "
"Use the tools available to see what was accomplished this week, what's still "
"overdue, how many notes were captured, and what's coming up in the next seven days. "
"Write a reflective recap that celebrates real progress and gently flags what's stuck.\n\n"
"Rules:\n"
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
"- If a category is empty, say so honestly rather than inventing items.\n"
"- Write flowing prose. No markdown, no bullet points.\n"
"- Aim for 5 to 8 sentences. Reflective and encouraging tone.\n\n"
)
else: # morning, midday, afternoon check-ins
base = (
f"You are the user's personal assistant giving a brief {slot} check-in. "
"Use tools to see what's changed since this morning. Focus on progress and "
"what's still unaddressed.\n\n"
"When checking tasks, call list_tasks at least twice:\n"
"- once with status=\"in_progress\" to see anything already being worked on "
"(regardless of due date — these can quietly drag past their due dates)\n"
"- once filtered by due date for what's coming up or overdue today\n\n"
"Rules:\n"
"- Call tools to see current state. Never assert facts without tool results.\n"
"- If nothing meaningful has changed, say so briefly — don't invent progress.\n"
"- 3 to 5 sentences, natural prose, no markdown.\n\n"
)
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}", ""]
base = tz_block + base
if profile_body:
base += f"User profile (tone and preferences):\n{profile_body}\n"
return base
# 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}"
def _agentic_user_trigger(slot: str, date_str: str) -> str:
"""Seed user-role message that kicks off the agentic run."""
labels = {
"compilation": "morning briefing",
"morning": "morning check-in",
"midday": "midday check-in",
"afternoon": "afternoon wrap-up",
"weekly_review": "weekly review",
}
label = labels.get(slot, f"{slot} briefing")
return f"Generate my {label} for {date_str}."
async def run_agentic_briefing(
user_id: int,
slot: str,
model: str,
conv_id: int | None = None,
rss_override: list[dict] | None = None,
) -> tuple[str, list[dict]]:
"""
Run the agentic briefing loop for a user and slot.
Uses the chat pipeline's tool-use loop with a curated read-only tool
subset and a slot-specific system prompt. Every fact the model states
is either derived from a tool result visible in the returned message
list or it's the model hallucinating — so follow-up chat in the same
conversation can hold the model to what the tool results actually show.
Returns ``(final_prose, message_list)`` where ``message_list`` is the
full sequence including system, user trigger, tool calls, and tool
results. Callers are expected to persist those intermediate turns
alongside the final prose so the receipts remain in conversation
history on follow-up.
If the loop fails or the model returns empty prose, returns
``("", [])`` and the caller should fall back to the legacy path.
"""
from fabledassistant.services.llm import stream_chat_with_tools, ChatChunk # noqa: F401
from fabledassistant.services.tools import execute_tool
from fabledassistant.services.briefing_tools import get_briefing_tools
from fabledassistant.services.user_profile import build_profile_context
profile_context = await build_profile_context(user_id)
tools = await get_briefing_tools(user_id)
if not tools:
logger.warning(
"Agentic briefing for user %d slot %s: no tools available — aborting",
user_id, slot,
)
return "", []
# Compute today's window in the user's local timezone so the model
# receives ready-to-use ISO 8601 boundaries and never has to do its
# own tz math when calling date-sensitive tools like list_events.
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
user_tz = ZoneInfo("UTC")
tz_name = "UTC"
now_local = datetime.now(user_tz)
today_iso = now_local.date().isoformat()
day_start = datetime(now_local.year, now_local.month, now_local.day, 0, 0, 0, tzinfo=user_tz)
day_end = datetime(now_local.year, now_local.month, now_local.day, 23, 59, 59, tzinfo=user_tz)
day_from_iso = day_start.isoformat()
day_to_iso = day_end.isoformat()
system_prompt = _agentic_system_prompt(
profile_context, slot, today_iso, tz_name, day_from_iso, day_to_iso,
)
messages: list[dict] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": _agentic_user_trigger(slot, today_iso)},
]
final_text = ""
for round_idx in range(_BRIEFING_AGENT_MAX_ROUNDS):
accumulated_content = ""
accumulated_tool_calls: list[dict] = []
try:
async for chunk in stream_chat_with_tools(
messages, model, tools=tools, think=False,
num_ctx=_BRIEFING_AGENT_NUM_CTX,
):
if chunk.type == "content" and chunk.content:
accumulated_content += chunk.content
elif chunk.type == "tool_calls" and chunk.tool_calls:
accumulated_tool_calls.extend(chunk.tool_calls)
except Exception:
logger.warning(
"Agentic briefing stream failed (user %d, slot %s, round %d)",
user_id, slot, round_idx, exc_info=True,
)
lines.append("")
return "", []
# 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("")
# Append the assistant turn (content + any tool calls) to history
assistant_msg: dict = {"role": "assistant", "content": accumulated_content}
if accumulated_tool_calls:
assistant_msg["tool_calls"] = accumulated_tool_calls
messages.append(assistant_msg)
# 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("")
# No tool calls → the model is done
if not accumulated_tool_calls:
final_text = accumulated_content.strip()
break
# 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("")
# Execute each tool call and append results as tool-role messages
for tc in accumulated_tool_calls:
fn = tc.get("function") or {}
tool_name = fn.get("name", "")
arguments = fn.get("arguments") or {}
if isinstance(arguments, str):
try:
import json as _json
arguments = _json.loads(arguments)
except Exception:
arguments = {}
# 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("")
# Default list_tasks to active statuses only so cancelled/done
# items don't slip into briefing prose. The model can still
# pass an explicit status filter when it wants something else.
if tool_name == "list_tasks" and not arguments.get("status"):
arguments["status"] = ["todo", "in_progress"]
return "\n".join(lines)
try:
if tool_name == "get_rss_items" and rss_override is not None:
# Use topic-scored/filtered items already computed by
# the briefing pipeline rather than the raw feed dump
# that execute_tool would return. Keeps the model's
# view of news aligned with the user's topic prefs
# and the sidebar's rss_item_ids metadata.
slim = [
{
"id": item.get("id"),
"title": item.get("title", ""),
"url": item.get("url", ""),
"source": item.get("feed_title", ""),
"summary": (item.get("content") or "")[:400],
"published_at": item.get("published_at"),
"topics": item.get("topics") or [],
}
for item in rss_override
]
result = {"success": True, "data": {"items": slim, "count": len(slim)}}
else:
result = await execute_tool(user_id, tool_name, arguments, conv_id=conv_id)
except Exception as exc:
logger.warning(
"Tool %s failed during agentic briefing: %s", tool_name, exc,
)
result = {"success": False, "error": str(exc)}
# Serialize the result compactly for the model's context
import json as _json
try:
result_str = _json.dumps(result, default=str)[:4000]
except Exception:
result_str = str(result)[:4000]
def _format_temp(value: float, unit: str) -> str:
"""Convert Celsius to the requested unit and format as an integer string."""
if unit == "F":
return f"{value * 9 / 5 + 32:.0f}"
return f"{value:.0f}"
messages.append({
"role": "tool",
"content": result_str,
"tool_name": tool_name,
})
else:
logger.warning(
"Agentic briefing hit max rounds (%d) for user %d slot %s — using last content",
_BRIEFING_AGENT_MAX_ROUNDS, user_id, slot,
)
# Walk back to find the last assistant message with non-empty content
for m in reversed(messages):
if m.get("role") == "assistant" and m.get("content"):
final_text = m["content"].strip()
break
return final_text, messages
# ── Main entry point ───────────────────────────────────────────────────────────
@@ -358,14 +323,16 @@ async def run_compilation(
model: str | None = None,
) -> tuple[str, dict]:
"""
Run the full two-lane briefing pipeline for a user and slot.
Returns (briefing_text, metadata_dict) where metadata contains
weather card data and rss_item_ids for frontend rendering.
Run the agentic briefing loop and gather UI metadata (RSS + weather).
Returns ``(briefing_text, metadata)`` where metadata contains
``rss_item_ids``, ``rss_items``, ``weather`` for frontend rendering,
and ``agentic_messages`` (the full tool-call sequence) for the
scheduler to persist as separate conversation rows.
"""
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
from fabledassistant.services.user_profile import build_profile_context
from fabledassistant.services.briefing_preferences import (
load_topic_preferences,
load_topic_reaction_scores,
@@ -373,27 +340,15 @@ async def run_compilation(
)
from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows
profile_context, temp_unit = await asyncio.gather(
build_profile_context(user_id),
_get_temp_unit(user_id),
)
# ── Pre-processing ──────────────────────────────────────────────────────────
include_topics, exclude_topics = await load_topic_preferences(user_id)
topic_scores = await load_topic_reaction_scores(user_id)
# Parallel raw gather — weather rows fetched in same gather to avoid extra DB round-trip
internal_data, external_data, weather_rows = await asyncio.gather(
_gather_internal(user_id),
external_data, weather_rows, temp_unit = await asyncio.gather(
_gather_external(user_id),
get_cached_weather_rows(user_id),
_get_temp_unit(user_id),
)
# Task change detection
all_tasks = internal_data.get("all_tasks_raw", [])
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
# RSS filtering
raw_rss = external_data.get("rss_items") or []
filtered_rss = score_and_filter_items(
raw_rss,
@@ -416,32 +371,19 @@ async def run_compilation(
if item.get("id")
]
# Weather staleness gate — returns None if data is >24h old
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None
# ── LLM Synthesis ──────────────────────────────────────────────────────────
# Build filtered internal data with only changed tasks
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]
# Build filtered external data (suppress weather prose — card handles it)
external_data_filtered = {
"rss_items": filtered_rss,
"weather": [],
}
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,
briefing_text, agentic_messages = await run_agentic_briefing(
user_id, slot, model, conv_id=None, rss_override=filtered_rss,
)
# ── Post-processing ─────────────────────────────────────────────────────────
await upsert_task_snapshots(user_id, all_tasks)
metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "weather": weather_card}
metadata: dict = {
"rss_item_ids": rss_item_ids,
"rss_items": rss_items_meta,
"weather": weather_card,
}
if agentic_messages:
metadata["agentic_messages"] = agentic_messages
if not briefing_text:
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
@@ -450,27 +392,26 @@ async def run_compilation(
return briefing_text, metadata
async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str:
async def run_slot_injection(
user_id: int,
slot: str,
model: str | None = None,
) -> tuple[str, dict]:
"""
Lighter update for 8am/12pm/4pm — gathers fresh data and produces a slot-specific
update prompt. Returns the text to inject as a new user→assistant exchange.
Lighter check-in update for 8am/12pm/4pm slots.
Runs the agentic loop with the slot-specific prompt. Returns
``(text, metadata)`` where metadata contains ``agentic_messages``
for the scheduler to persist.
"""
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
internal_data, external_data, temp_unit = await asyncio.gather(
_gather_internal(user_id),
_gather_external(user_id),
_get_temp_unit(user_id),
text, agentic_messages = await run_agentic_briefing(
user_id, slot, model, conv_id=None,
)
system = (
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."
)
return await _llm_synthesise(
system,
_unified_user_prompt(internal_data, external_data, slot, temp_unit),
model,
)
metadata: dict = {}
if agentic_messages:
metadata["agentic_messages"] = agentic_messages
return text, metadata
@@ -13,7 +13,7 @@ functions wrapped with asyncio.run_coroutine_threadsafe().
import asyncio
import logging
from datetime import date, datetime, time, timedelta
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler
@@ -36,6 +36,11 @@ SLOTS = [
("afternoon", 16, 0),
]
# Weekly review runs Sunday at 6pm by default
WEEKLY_REVIEW_DAY = "sun" # APScheduler day_of_week format
WEEKLY_REVIEW_HOUR = 18
WEEKLY_REVIEW_MINUTE = 0
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -102,6 +107,26 @@ def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
replace_existing=True,
misfire_grace_time=3600,
)
# Weekly review job — runs once per week
weekly_jid = _job_id(user_id, "weekly_review")
weekly_on = enabled_slots.get("weekly_review", True)
if weekly_on:
_scheduler.add_job(
_run_user_slot_sync,
CronTrigger(
day_of_week=WEEKLY_REVIEW_DAY,
hour=WEEKLY_REVIEW_HOUR,
minute=WEEKLY_REVIEW_MINUTE,
timezone=tz,
),
args=[user_id, "weekly_review"],
id=weekly_jid,
replace_existing=True,
misfire_grace_time=7200,
)
elif _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz)
@@ -113,6 +138,9 @@ def _remove_user_jobs(user_id: int) -> None:
jid = _job_id(user_id, slot_name)
if _scheduler.get_job(jid):
_scheduler.remove_job(jid)
weekly_jid = _job_id(user_id, "weekly_review")
if _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Removed briefing jobs for user %d", user_id)
@@ -134,6 +162,170 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N
# ── Job execution ─────────────────────────────────────────────────────────────
async def _auto_pause_stale_projects(user_id: int) -> list[str]:
"""Pause active projects with no note/task activity in 14+ days. Returns paused project titles."""
from sqlalchemy import select as _sel, func as _func
from fabledassistant.models.project import Project
from fabledassistant.models.note import Note
from fabledassistant.models import async_session as _session
paused: list[str] = []
threshold = datetime.now(timezone.utc) - timedelta(days=14)
try:
async with _session() as session:
projects = (await session.execute(
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
)).scalars().all()
for p in projects:
latest = (await session.execute(
_sel(_func.max(Note.updated_at)).where(Note.project_id == p.id)
)).scalar()
if latest is None or latest < threshold:
p.status = "paused"
paused.append(p.title or "Untitled")
if paused:
await session.commit()
logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused)
except Exception:
logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True)
return paused
async def _persist_agentic_messages(
conv_id: int,
slot: str,
metadata: dict | None,
) -> None:
"""Persist the intermediate turns from an agentic briefing run.
``metadata["agentic_messages"]`` is the full message list the agent
generated — system prompt, user trigger, assistant tool-call turns,
tool-role results, and the final assistant prose.
To stay compatible with the existing chat loader in ``routes/chat.py``,
tool results are folded back into the parent assistant message's
``tool_calls[i]["result"]`` field rather than being persisted as
separate ``role="tool"`` rows. This matches how regular chat
persists agentic turns, so the follow-up chat endpoint can rehydrate
the tool sequence using its existing logic.
Persists everything except the system prompt (implicit in the chat
pipeline) and the final assistant prose (the caller posts that
separately with the user-facing metadata block). The synthetic user
trigger message is persisted so Ollama sees a user→assistant→user
sequence rather than an orphaned assistant reply — it's tagged as
intermediate so the UI can hide it.
Legacy (non-agentic) briefings have no ``agentic_messages`` and this
function is a no-op.
"""
from fabledassistant.services.briefing_conversations import post_message
if not metadata:
return
agentic_messages = metadata.get("agentic_messages") or []
if not agentic_messages:
return
# Drop the system prompt (index 0) and the final assistant prose
# (last item). The caller posts the final prose as its own message.
middle = agentic_messages[1:-1]
# Walk the middle sequence, pairing each assistant tool-call turn
# with the tool-role results that immediately follow it.
i = 0
n = len(middle)
while i < n:
m = middle[i]
role = m.get("role", "")
content = m.get("content", "") or ""
tag = {"briefing_slot": slot, "briefing_intermediate": True}
if role == "assistant" and m.get("tool_calls"):
# Collect subsequent tool-role results, matching them
# positionally onto this assistant's tool_calls. Normalise
# each entry to the flat storage format the chat loader
# expects: {"function": <name>, "arguments": <args>,
# "result": <result>, "status": "success"|"error"}.
raw_tool_calls = list(m["tool_calls"])
flat_tool_calls: list[dict] = []
result_idx = 0
j = i + 1
import json as _json
for raw_tc in raw_tool_calls:
fn = raw_tc.get("function") or {}
name = fn.get("name") if isinstance(fn, dict) else str(fn)
arguments = fn.get("arguments") if isinstance(fn, dict) else {}
if isinstance(arguments, str):
try:
arguments = _json.loads(arguments)
except Exception:
arguments = {}
# Pair up with the next available tool-role message
parsed_result: object = {}
status = "success"
if j < n and middle[j].get("role") == "tool":
tool_content = middle[j].get("content", "") or ""
try:
parsed_result = _json.loads(tool_content)
except Exception:
parsed_result = tool_content
if isinstance(parsed_result, dict) and parsed_result.get("success") is False:
status = "error"
j += 1
result_idx += 1
flat_tool_calls.append({
"function": name,
"arguments": arguments,
"result": parsed_result,
"status": status,
})
try:
await post_message(
conv_id, "assistant", content,
metadata=tag,
tool_calls=flat_tool_calls,
)
except Exception:
logger.warning(
"Failed to persist agentic assistant turn for conv %d slot %s",
conv_id, slot, exc_info=True,
)
i = j # skip the tool results we just folded in
continue
if role == "tool":
# Unpaired tool result — shouldn't normally happen, but be
# defensive and persist it as an assistant-visible note so we
# don't lose the receipt entirely.
i += 1
continue
if role == "user":
# Skip the synthetic user trigger ("Generate my morning briefing…").
# Persisting it would recreate the exact "[Midday briefing update]"
# problem PR 2 is designed to eliminate: fake user messages
# cluttering chat history. The LLM can follow an all-assistant
# sequence just fine since the chat endpoint injects the real
# system prompt on follow-up.
i += 1
continue
# assistant without tool_calls — persist as-is (rare intermediate)
try:
await post_message(conv_id, role, content, metadata=tag)
except Exception:
logger.warning(
"Failed to persist agentic %s message for conv %d slot %s",
role, conv_id, slot, exc_info=True,
)
i += 1
async def _run_slot_for_user(user_id: int, slot: str) -> None:
"""Execute one slot job for one user."""
from fabledassistant.services.briefing_conversations import (
@@ -164,6 +356,9 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
if slot == "compilation":
# Auto-pause stale projects before compiling the briefing
await _auto_pause_stale_projects(user_id)
# Refresh external data first
try:
import json
@@ -188,14 +383,28 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
conv = await get_or_create_today_conversation(user_id, model)
text, metadata = await run_compilation(user_id, slot, model)
if text:
await post_message(conv.id, "assistant", text, metadata=metadata)
# Persist the agentic tool-call sequence as its own messages
# so follow-up chat can see the receipts. Each intermediate
# message is tagged with briefing_slot so the chat context
# loader can decide whether to include them in history.
await _persist_agentic_messages(conv.id, slot, metadata)
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
final_meta["briefing_slot"] = slot
await post_message(conv.id, "assistant", text, metadata=final_meta)
else:
conv = await get_or_create_today_conversation(user_id, model)
text = await run_slot_injection(user_id, slot, model)
text, slot_metadata = await run_slot_injection(user_id, slot, model)
if text:
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
await post_message(conv.id, "assistant", text)
# No more synthetic "[Midday briefing update]" user-role
# messages. Slot updates are plain assistant messages tagged
# with briefing_slot so the chat endpoint can filter them
# from the LLM's view of history on follow-ups (they remain
# visible in the UI).
await _persist_agentic_messages(conv.id, slot, slot_metadata)
final_meta = {k: v for k, v in slot_metadata.items() if k != "agentic_messages"}
final_meta["briefing_slot"] = slot
await post_message(conv.id, "assistant", text, metadata=final_meta)
try:
from fabledassistant.services.push import send_push_notification
@@ -235,10 +444,13 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
and append them to the briefing profile note.
"""
from fabledassistant.services.user_profile import append_observations
from fabledassistant.services.briefing_pipeline import _llm_synthesise
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.tz import user_today
from fabledassistant.models.conversation import Conversation, Message
yesterday = (date.today() - timedelta(days=1))
# User-local "yesterday" so closeout always targets the day that just
# ended in the user's timezone, regardless of container TZ.
yesterday = (await user_today(user_id)) - timedelta(days=1)
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
@@ -268,7 +480,17 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
"Example: '- User skipped news about finance', '- Prefers weather for home location first'. "
"If nothing notable, output only: (nothing to note)"
)
observations = await _llm_synthesise(system, transcript, model)
try:
observations = (await generate_completion(
[
{"role": "system", "content": system},
{"role": "user", "content": transcript},
],
model,
)).strip()
except Exception:
logger.warning("Profile closeout synthesis failed for user %d", user_id, exc_info=True)
observations = ""
if observations and "(nothing to note)" not in observations.lower():
await append_observations(user_id, observations)
@@ -0,0 +1,9 @@
"""Briefing tool subset — delegates to the registry's ``briefing=True`` filter.
Tools are opted-in to briefings via ``@tool(briefing=True)`` in their
respective module, so there is no separate allowlist to maintain here.
"""
from fabledassistant.services.tools import get_briefing_tools
__all__ = ["get_briefing_tools"]
+3
View File
@@ -187,6 +187,7 @@ async def add_message(
context_note_id: int | None = None,
status: str | None = None,
tool_calls: list | None = None,
msg_metadata: dict | None = None,
) -> Message:
async with async_session() as session:
kwargs: dict = dict(
@@ -199,6 +200,8 @@ async def add_message(
kwargs["status"] = status
if tool_calls is not None:
kwargs["tool_calls"] = tool_calls
if msg_metadata is not None:
kwargs["msg_metadata"] = msg_metadata
msg = Message(**kwargs)
session.add(msg)
# Touch conversation updated_at
+19 -13
View File
@@ -40,29 +40,35 @@ _EMAIL_LOGO_SVG = (
def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Assistant template."""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="padding:32px 16px;">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
</head>
<body style="margin:0;padding:0;background:#f5f3ff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<div style="padding:36px 16px 48px;">
<div style="max-width:520px;margin:0 auto;">
<div style="background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<!-- Card -->
<div style="background:#ffffff;border:1px solid #ddd6fe;border-radius:14px;overflow:hidden;box-shadow:0 4px 24px rgba(109,40,217,0.08);">
<!-- Header -->
<div style="background:#6366f1;padding:20px 24px;text-align:center;">
<div style="margin-bottom:6px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:17px;font-weight:600;letter-spacing:0.01em;">Fabled Assistant</span>
<div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
<div style="margin-bottom:8px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Assistant</span>
</div>
<p style="margin:0;color:#c7d2fe;font-size:13px;">{title}</p>
<p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
</div>
<!-- Body -->
<div style="padding:28px 24px;">
<div style="padding:32px 28px;color:#1e1b4b;">
{body}
</div>
<!-- Footer -->
<div style="border-top:1px solid #e5e7eb;padding:14px 24px;text-align:center;background:#f9fafb;">
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent by your Fabled Assistant instance.</p>
<div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
</div>
</div>
@@ -169,7 +175,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
async def send_test_email(to: str) -> None:
"""Send a branded test email."""
body = """
<p style="margin:0 0 12px;color:#111827;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
"""
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
+20 -9
View File
@@ -7,7 +7,7 @@ import uuid
from datetime import datetime, timedelta, timezone
from dateutil.rrule import rrulestr
from sqlalchemy import or_, select
from sqlalchemy import and_, or_, select
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
@@ -85,19 +85,30 @@ async def list_events(
dicts (same shape as Event.to_dict()).
"""
async with async_session() as session:
# Match strategy:
# - Recurring events: fetch all, expand via rrule below.
# - Non-recurring with an end_dt: standard overlap — starts before
# date_to and ends after date_from.
# - Non-recurring with no end_dt: treat as a point event at
# start_dt, include only if start_dt falls within the window.
# (Previously this branch matched any event with a null end_dt,
# returning all past events as "happening today".)
result = await session.execute(
select(Event).where(
Event.user_id == user_id,
# Base window: non-recurring events must overlap range;
# recurring events always need to be fetched so they can be expanded.
or_(
Event.recurrence.isnot(None),
Event.start_dt <= date_to,
),
or_(
Event.end_dt.is_(None),
Event.end_dt >= date_from,
Event.recurrence.isnot(None),
and_(
Event.recurrence.is_(None),
Event.start_dt <= date_to,
or_(
Event.end_dt >= date_from,
and_(
Event.end_dt.is_(None),
Event.start_dt >= date_from,
),
),
),
),
).order_by(Event.start_dt)
)
+210 -84
View File
@@ -36,73 +36,91 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
# ---------------------------------------------------------------------------
# Conditional thinking classifier
# ---------------------------------------------------------------------------
# Patterns that force think=True even on short messages
_THINK_FORCE = re.compile(
r"\b("
r"analyz|compar|explain\s+why|help\s+me\s+(think|plan|understand|figure\s+out)|"
r"step[- ]by[- ]step|debug|troubleshoot|diagnos|"
r"pros\s+and\s+cons|trade[- ]?off|"
r"architect|design\s+(a|the|my|this)|"
r"write\s+a\s+(detailed|long|comprehensive|full)|"
r"brainstorm|outline\s+(a|the|my)|"
r"what\s+(are|is)\s+the\s+(best|difference|relationship|impact|implication)|"
r"how\s+(do|does|should|would|can)\s+.{0,40}\s+work|"
r"why\s+(is|are|does|do|did|would|should)\b"
r")",
re.IGNORECASE,
)
async def _maybe_save_article_discussion_note(
user_id: int, conv_id: int, reply_content: str,
) -> None:
"""Persist a seeded article-discussion's first reply as a Note.
# Patterns that force think=False regardless of message length
_THINK_SKIP = re.compile(
r"^(hi|hey|hello|thanks|thank\s+you|ok|okay|got\s+it|sounds\s+good|"
r"great|perfect|sure|yes|no|yep|nope|nice|cool|awesome|"
r"what('s| is) \d|what time|how many|remind me|add (a |an )?(task|note|reminder)|"
r"create (a |an )?(task|note)|delete|update|mark .{0,30} (done|complete))\b",
re.IGNORECASE,
)
Fires after ``run_generation`` completes. Looks for a synthetic
read_article seed message on the conversation; if found AND the linked
``rss_items`` row has no ``discussion_note_id`` yet, saves ``reply_content``
as a Note, tags it, and writes the backlink. Subsequent discuss clicks on
the same article are a no-op (already linked).
_WORD_COUNT_THRESHOLD = 60 # messages over this word count always use think=True
_SHORT_MESSAGE_THRESHOLD = 12 # messages under this always use think=False
def _should_think(user_content: str, think_requested: bool) -> bool:
"""Return whether extended thinking should be used for this request.
If the caller didn't request thinking, we never enable it. If they did,
we check whether the message is complex enough to warrant the overhead.
Failures are logged and swallowed — the chat UI should never break because
Note persistence hit a snag.
"""
if not think_requested:
return False
try:
if not reply_content or not reply_content.strip():
return
from sqlalchemy import select as _select
from fabledassistant.models.conversation import Message as _Message
from fabledassistant.models.rss_feed import RssItem as _RssItem
from fabledassistant.services.notes import create_note
text = user_content.strip()
word_count = len(text.split())
async with async_session() as session:
result = await session.execute(
_select(_Message)
.where(_Message.conversation_id == conv_id)
.order_by(_Message.id.asc())
)
messages = result.scalars().all()
seed_meta = None
for m in messages:
meta = m.msg_metadata or {}
if meta.get("article_seed") and meta.get("rss_item_id"):
seed_meta = meta
break
if seed_meta is None:
return
item_id = int(seed_meta["rss_item_id"])
item = await session.get(_RssItem, item_id)
if item is None or item.discussion_note_id is not None:
return
article_title = (item.title or "Untitled article").strip()
article_url = item.url
article_topics = list(item.topics or [])
if word_count <= _SHORT_MESSAGE_THRESHOLD:
return False
if _THINK_SKIP.match(text):
return False
if word_count >= _WORD_COUNT_THRESHOLD:
return True
if "```" in text:
return True
if _THINK_FORCE.search(text):
return True
return False
note_title = f"Article: {article_title}"[:200]
body_parts = [f"**Source:** {article_url}"] if article_url else []
body_parts.append(reply_content.strip())
note_body = "\n\n".join(body_parts)
tags = ["article-summary"] + [t for t in article_topics if t]
note = await create_note(
user_id=user_id,
title=note_title,
body=note_body,
tags=tags,
entity_meta={
"source": "article_discussion",
"rss_item_id": item_id,
"url": article_url,
"conversation_id": conv_id,
},
)
async with async_session() as session:
fresh = await session.get(_RssItem, item_id)
if fresh is not None and fresh.discussion_note_id is None:
fresh.discussion_note_id = note.id
await session.commit()
logger.info(
"Saved article-discussion summary as note %d for rss_item %d (conv %d)",
note.id, item_id, conv_id,
)
except Exception:
logger.warning(
"Failed to persist article-discussion note for conv %d",
conv_id, exc_info=True,
)
# Human-readable labels for each tool, shown in the status indicator
_TOOL_LABELS: dict[str, str] = {
"create_task": "Creating task",
"create_note": "Creating note",
"update_note": "Updating note",
"delete_note": "Deleting note",
"delete_task": "Deleting task",
"get_note": "Reading note",
"create_note": "Creating note/task",
"update_note": "Updating note/task",
"delete_note": "Deleting note/task",
"read_note": "Reading note",
"list_notes": "Listing notes",
"list_tasks": "Searching tasks",
"search_notes": "Searching notes (semantic)",
@@ -118,31 +136,45 @@ _TOOL_LABELS: dict[str, str] = {
async def _generate_title(messages: list[dict], user_id: int) -> str:
"""Ask the LLM for a concise conversation title."""
# Build conversation text like summarize_conversation_as_note
conv_lines = []
"""Ask the LLM for a concise conversation title.
Only uses user messages to avoid feeding tool-call JSON, system prompt
fragments, or other noise into the title generator. Caps input length
to keep the task fast and focused.
"""
user_texts = []
for m in messages:
if m["role"] == "system":
continue
label = "User" if m["role"] == "user" else "Assistant"
conv_lines.append(f"{label}: {m['content']}")
# Keep only last 6 pairs worth of text
conv_lines = conv_lines[-12:]
if m["role"] == "user":
content = (m.get("content") or "").strip()
if content:
user_texts.append(content[:300])
if not user_texts:
return ""
# First + last user messages capture intent best
if len(user_texts) > 2:
user_texts = [user_texts[0], user_texts[-1]]
prompt_messages = [
{
"role": "system",
"role": "user",
"content": (
"Generate a concise 3-8 word title for this conversation. "
"Reply with ONLY the title, no quotes or punctuation."
"Generate a concise 3-8 word title for a conversation that started with:\n\n"
+ "\n\n".join(user_texts)
+ "\n\nReply with ONLY the title. No quotes, no punctuation, no explanation."
),
},
{"role": "user", "content": "\n\n".join(conv_lines)},
]
bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
title = await generate_completion(prompt_messages, bg_model, max_tokens=30)
title = await generate_completion(prompt_messages, bg_model, max_tokens=30, num_ctx=1024)
# Strip common LLM noise: quotes, thinking tags, role labels
title = title.strip().strip('"\'').strip()
return title[:100] if title else ""
for prefix in ("Title:", "title:", "Assistant:", "User:"):
if title.startswith(prefix):
title = title[len(prefix):].strip()
# Drop anything after a newline (model sometimes adds explanation)
if "\n" in title:
title = title.split("\n")[0].strip()
return title[:80] if title else ""
async def _update_message(
@@ -261,25 +293,33 @@ async def run_generation(
voice_speech_style=voice_speech_style,
)
# Pick the smallest context tier that fits the current messages.
# Pick the smallest context tier that fits the current messages AND the
# tool schemas (which can be 6-10K tokens on their own with ~40 tools).
# Using the minimum needed tier reduces KV cache size and speeds up prefill.
num_ctx = pick_num_ctx(messages)
num_ctx = pick_num_ctx(messages, tools=tools)
logger.debug("Adaptive num_ctx=%d for conv %d", num_ctx, conv_id)
# Emit context event
buf.append_event("context", {"context": context_meta})
# Apply thinking classifier — downgrade think=True for simple/conversational messages
think = _should_think(user_content, think)
# Always think on qwen3-class models: reasoning mode is the only reliable
# path for the tool-call template. Content-based gating was tried in 87fcaa6
# but exposed silent-generation failures on short tool-intent prompts, since
# the classifier had no way to tell that "create a task" needs a tool call.
think_requested = think
think = True
t_start = time.monotonic()
timing: dict = {
"think_requested": think_requested,
"think": think,
"num_ctx": num_ctx,
"tools": [],
"rounds": 0,
"prompt_tokens": None,
"output_tokens": None,
"first_token_ms": None,
"thinking_ms": None,
"ttft_ms": None,
"generation_ms": None,
"total_ms": None,
@@ -305,17 +345,35 @@ async def run_generation(
buf.append_event("status", {"status": "Generating response..." if _round == 0 else "Composing response..."})
t_stream = time.monotonic()
approx_msg_chars = sum(len(str(m.get("content", ""))) for m in messages)
round_content_start = len(buf.content_so_far)
round_output_tokens_start = timing.get("output_tokens") or 0
round_prompt_tokens_start = timing.get("prompt_tokens") or 0
logger.info(
"CTX_DIAG round_start conv=%d round=%d num_ctx=%d msgs=%d approx_chars=%d think=%s",
conv_id, _round, num_ctx, len(messages), approx_msg_chars, think,
)
async for chunk in _stream_with_retry(messages, model, tools, think, num_ctx=num_ctx):
if buf.cancel_event.is_set():
cancelled = True
break
if chunk.type == "thinking":
if timing["first_token_ms"] is None:
timing["first_token_ms"] = int((time.monotonic() - t_start) * 1000)
buf.append_event("thinking_chunk", {"chunk": chunk.content})
elif chunk.type == "content":
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
now_ms = int((time.monotonic() - t_start) * 1000)
timing["ttft_ms"] = now_ms
if timing["first_token_ms"] is None:
# No thinking phase occurred — first token IS content.
timing["first_token_ms"] = now_ms
else:
# Thinking phase duration = gap between first thinking
# token and first content token.
timing["thinking_ms"] = now_ms - timing["first_token_ms"]
buf.content_so_far += chunk.content
clean = _TOOL_CALL_MARKER.sub("", chunk.content)
if clean:
@@ -362,8 +420,9 @@ async def run_generation(
buf.content_so_far += done_text
except Exception as e:
logger.exception("Research pipeline failed for topic: %s", topic)
result = {"success": False, "error": str(e)}
err_text = f"\nResearch failed: {e}"
err_msg = str(e) or f"{type(e).__name__}: unexpected error"
result = {"success": False, "error": err_msg}
err_text = f"\nResearch failed: {err_msg}"
buf.append_event("chunk", {"chunk": err_text})
buf.content_so_far += err_text
research_completed = True
@@ -392,6 +451,21 @@ async def run_generation(
all_tool_calls.append(tool_record)
buf.append_event("tool_call", {"tool_call": tool_record})
round_content_added = len(buf.content_so_far) - round_content_start
round_output_tokens_added = (timing.get("output_tokens") or 0) - round_output_tokens_start
round_prompt_tokens = (timing.get("prompt_tokens") or 0) - round_prompt_tokens_start
headroom = num_ctx - round_prompt_tokens if round_prompt_tokens else None
is_silent = (
not round_tool_calls
and round_content_added == 0
and round_output_tokens_added > 0
)
logger.info(
"CTX_DIAG round_end conv=%d round=%d think=%s prompt_tokens=%d output_tokens=%d headroom=%s content_added=%d tool_calls=%d silent=%s",
conv_id, _round, think, round_prompt_tokens, round_output_tokens_added,
headroom, round_content_added, len(round_tool_calls), is_silent,
)
timing["generation_ms"] = int((time.monotonic() - t_stream) * 1000)
if cancelled:
@@ -426,6 +500,29 @@ async def run_generation(
# Strip model artifacts from final content
buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far)
# Silent-generation safety net: the model burned output tokens but
# nothing landed in content or tool_calls (seen with qwen3:14b when
# its tool-call emission doesn't parse). Show a visible fallback so
# the user isn't staring at an empty bubble.
if (
not cancelled
and not buf.content_so_far.strip()
and not all_tool_calls
and (timing.get("output_tokens") or 0) > 0
):
logger.warning(
"Silent generation for conv %d: output_tokens=%s but empty content "
"and no tool calls (model=%s)",
conv_id, timing.get("output_tokens"), model,
)
fallback = (
"I wasn't able to produce a usable response — the model generated "
"tokens that couldn't be parsed as content or a tool call. "
"Please try rephrasing, or try again."
)
buf.content_so_far = fallback
buf.append_event("chunk", {"chunk": fallback})
# Final save
logger.info("Generation complete for conv %d: content_length=%d, tool_calls=%d",
conv_id, len(buf.content_so_far), len(all_tool_calls))
@@ -438,9 +535,13 @@ async def run_generation(
timing["total_ms"] = int((time.monotonic() - t_start) * 1000)
logger.info(
"Generation timing for conv %d: total=%dms ttft=%s tools=%s generation=%s",
conv_id, timing["total_ms"], timing["ttft_ms"],
[(t["name"], t["ms"]) for t in timing["tools"]], timing["generation_ms"],
"Generation timing for conv %d: total=%dms think=%s(req=%s) first_token=%s "
"thinking=%s ttft=%s generation=%s tools=%s",
conv_id, timing["total_ms"],
timing["think"], timing["think_requested"],
timing["first_token_ms"], timing["thinking_ms"], timing["ttft_ms"],
timing["generation_ms"],
[(t["name"], t["ms"]) for t in timing["tools"]],
)
try:
await log_generation(user_id, conv_id, model, timing)
@@ -485,10 +586,35 @@ async def run_generation(
msg_count = len(non_system)
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
# Persist article-discussion seed conversations as a Note on their
# first assistant reply. This makes "Discuss" summaries part of RAG
# so the knowledge base stops being amnesiac about articles the user
# has already engaged with. The hook detects a seeded conversation by
# finding a synthetic read_article assistant message whose
# msg_metadata carries ``article_seed: True`` and whose rss_items row
# has no discussion_note_id yet. Fire-and-forget so the done event
# lands immediately.
asyncio.create_task(_maybe_save_article_discussion_note(
user_id, conv_id, buf.content_so_far,
))
if should_gen_title:
title_messages = messages + [
{"role": "assistant", "content": buf.content_so_far}
# Feed the title model the *raw* conversation turns only — never
# the post-build_context ``messages`` list. ``build_context``
# prepends RAG snippets, RSS excerpts, URL content, and briefing
# article dumps INTO the user message string itself, so filtering
# by role="user" downstream still surfaces that noise as the
# "user's message". That pollution caused wildly-wrong titles
# (bug #109) — the small background model was staring at article
# excerpts instead of what the user actually typed. Pass the
# original history + the raw user_content + the assistant reply.
title_messages: list[dict] = [
{"role": m["role"], "content": m.get("content") or ""}
for m in history
if m.get("role") in ("user", "assistant")
]
title_messages.append({"role": "user", "content": user_content})
title_messages.append({"role": "assistant", "content": buf.content_so_far})
async def _bg_title() -> None:
try:
+107 -40
View File
@@ -29,10 +29,15 @@ def _note_to_item(note: Note) -> dict:
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
item["birthday"] = meta.get("birthday", "")
item["organization"] = meta.get("organization", "")
item["address"] = meta.get("address", "")
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
item["website"] = meta.get("website", "")
item["category"] = meta.get("category", "")
elif note.entity_type == "list":
# Parse markdown task list syntax into structured items
body = note.body or ""
@@ -46,6 +51,14 @@ def _note_to_item(note: Note) -> dict:
item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body
# Task fields — override note_type and add status/priority/due_date
if note.is_task:
item["note_type"] = "task"
item["status"] = note.status
item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None
return item
@@ -69,17 +82,15 @@ async def query_knowledge(
)
async with async_session() as session:
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.status.is_(None)) # exclude tasks
)
base = select(Note).where(Note.user_id == user_id)
if note_type:
base = base.where(Note.note_type == note_type)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
# Exclude tasks — already done above; also exclude any legacy nulls
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
# All types including tasks
pass
for tag in tags:
base = base.where(Note.tags.contains([tag]))
@@ -111,31 +122,74 @@ async def _semantic_knowledge_search(
limit: int,
offset: int,
) -> tuple[list[dict], int]:
"""Semantic search over knowledge objects, with SQL filters applied post-rank."""
"""Hybrid search: keyword matches first (title/body ILIKE), then semantic results.
Exact keyword matches always rank above semantic-only matches so that
searching for a name like "Weston" surfaces the note with that title
before conceptually related notes.
"""
# 1. Keyword search — title and body ILIKE
keyword_notes: list[Note] = []
try:
async with async_session() as session:
pattern = f"%{q}%"
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Title matches first, then body-only matches, newest first within each
base = base.order_by(
Note.title.ilike(pattern).desc(),
Note.updated_at.desc(),
).limit(limit * 2)
keyword_notes = list((await session.execute(base)).scalars().all())
except Exception:
logger.warning("Keyword search failed", exc_info=True)
# 2. Semantic search — conceptual similarity
semantic_notes: list[Note] = []
try:
from fabledassistant.services.embeddings import semantic_search_notes
# Fetch a larger candidate set to allow for filtering
is_task_filter = True if note_type == "task" else (False if note_type else None)
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 8),
limit=min(200, limit * 4),
threshold=0.3,
is_task=False,
is_task=is_task_filter,
)
for _score, note in candidates:
if note_type == "task" and not note.is_task:
continue
elif note_type and note_type != "task" and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
semantic_notes.append(note)
except Exception:
logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True)
return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset)
logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
results = []
for _score, note in candidates:
if note_type and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
results.append(note)
# 3. Merge — keyword matches first, then semantic (deduplicated)
seen_ids: set[int] = set()
merged: list[Note] = []
for note in keyword_notes:
if note.id not in seen_ids:
seen_ids.add(note.id)
merged.append(note)
for note in semantic_notes:
if note.id not in seen_ids:
seen_ids.add(note.id)
merged.append(note)
total = len(results)
page_items = results[offset: offset + limit]
total = len(merged)
page_items = merged[offset: offset + limit]
return [_note_to_item(n) for n in page_items], total
@@ -145,12 +199,13 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
base = base.where(Note.note_type == note_type)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
pass
stmt = base.distinct().order_by("tag")
rows = list((await session.execute(stmt)).scalars().all())
return [r for r in rows if r]
@@ -159,6 +214,7 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> dict[str, int]:
"""Return per-type count of knowledge objects for the sidebar display."""
async with async_session() as session:
# Count non-task types
stmt = (
select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id)
@@ -170,11 +226,23 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
for tag in tags:
stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows}
# Ensure all types present even if zero
for t in ("note", "person", "place", "list"):
counts = {row[0]: row[1] for row in rows}
# Count tasks separately (is_task = status IS NOT NULL)
task_stmt = (
select(func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.isnot(None))
)
if tags:
for tag in tags:
task_stmt = task_stmt.where(Note.tags.contains([tag]))
task_count: int = (await session.execute(task_stmt)).scalar_one()
counts["task"] = task_count
for t in ("note", "person", "place", "list", "task"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list"))
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
return counts
@@ -197,15 +265,14 @@ async def query_knowledge_ids(
return [item["id"] for item in items], total
async with async_session() as session:
base = (
select(Note.id)
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
base = base.where(Note.note_type == note_type)
base = select(Note.id).where(Note.user_id == user_id)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
pass
for tag in tags:
base = base.where(Note.tags.contains([tag]))
+129 -30
View File
@@ -26,12 +26,32 @@ logger = logging.getLogger(__name__)
_CTX_TIERS = (8192, 16384, 32768)
def pick_num_ctx(messages: list[dict]) -> int:
"""Return the smallest context tier that fits *messages* with 25% headroom.
def keep_alive_for(model: str) -> str:
"""Return the Ollama keep_alive duration for *model*.
Background models get a shorter window because they're called
sporadically; the main interactive model gets a longer one so it
stays warm between user messages.
"""
if model == Config.OLLAMA_BACKGROUND_MODEL:
return Config.OLLAMA_KEEP_ALIVE_BACKGROUND
return Config.OLLAMA_KEEP_ALIVE_MAIN
def pick_num_ctx(messages: list[dict], tools: list[dict] | None = None) -> int:
"""Return the smallest context tier that fits *messages* + *tools* with 25% headroom.
The ``tools`` JSON schemas are a large, often-overlooked chunk of the prompt.
With ~40 tools in the registry the schemas alone can be 6-10K tokens — enough
that omitting them from the estimate causes silent prompt truncation.
Stays at or below Config.OLLAMA_NUM_CTX (the configured ceiling).
"""
total_chars = sum(len(m.get("content") or "") for m in messages)
if tools:
# Serialize the same way Ollama will see them. json.dumps gives us a
# faithful char count for the schema payload without any guesswork.
total_chars += len(json.dumps(tools))
estimated_tokens = int(total_chars / 3.5)
needed = int(estimated_tokens * 1.25) + 256 # 25% headroom + output buffer
cap = Config.OLLAMA_NUM_CTX
@@ -58,7 +78,14 @@ RAG_AUTO_SNIPPET = 4000
async def get_installed_models() -> set[str]:
"""Return set of installed Ollama model names (with and without :latest)."""
"""Return set of installed Ollama model names (with and without :latest).
Tags are normalized to lowercase. Ollama stores whatever casing was used at
pull time (``ollama pull gemma3:12B`` keeps the ``B``), but inference calls
against the capitalized tag can 400 — the two code paths are inconsistent.
Lowercasing on read avoids exposing that mixed-case name in the UI and
keeps the validation check below from accepting a form Ollama will reject.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
@@ -66,7 +93,7 @@ async def get_installed_models() -> set[str]:
data = resp.json()
names: set[str] = set()
for m in data.get("models", []):
name = m["name"]
name = m["name"].lower()
names.add(name)
if name.endswith(":latest"):
names.add(name.removesuffix(":latest"))
@@ -78,6 +105,9 @@ async def get_installed_models() -> set[str]:
async def ensure_model(model: str) -> None:
"""Check if model exists in Ollama, pull if missing."""
# Match the lowercase normalization in get_installed_models so a legacy
# mixed-case setting doesn't force a spurious re-pull at startup.
model = model.lower()
try:
installed = await get_installed_models()
if model in installed or f"{model}:latest" in installed:
@@ -129,6 +159,24 @@ async def wait_for_model_loaded(model: str, timeout: float = 90.0) -> bool:
await asyncio.sleep(min(2.0, remaining))
async def _raise_ollama_error(resp: httpx.Response, model: str) -> None:
"""On a non-2xx Ollama response, log its body before raising.
``resp.raise_for_status()`` alone throws away Ollama's error body, which
carries the actual reason (e.g. ``"model does not support tools"``,
``"context length exceeded"``). Reading the body first means failures
surface a usable message instead of a bare HTTPStatusError.
"""
if resp.status_code < 400:
return
body = (await resp.aread()).decode("utf-8", errors="replace").strip()
logger.error(
"Ollama /api/chat %d for model=%s: %s",
resp.status_code, model, body[:500],
)
resp.raise_for_status()
async def stream_chat(
messages: list[dict],
model: str,
@@ -145,7 +193,7 @@ async def stream_chat(
merged_options = {"num_ctx": num_ctx or Config.OLLAMA_NUM_CTX}
if options:
merged_options.update(options)
payload: dict = {"model": model, "messages": messages, "stream": True, "options": merged_options, "think": think, "keep_alive": "2h"}
payload: dict = {"model": model, "messages": messages, "stream": True, "options": merged_options, "think": think, "keep_alive": keep_alive_for(model)}
# read=None: no per-chunk timeout — Ollama may pause for any duration while
# processing a large input context before the first token arrives.
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0)) as client:
@@ -154,7 +202,7 @@ async def stream_chat(
f"{Config.OLLAMA_URL}/api/chat",
json=payload,
) as resp:
resp.raise_for_status()
await _raise_ollama_error(resp, model)
async for line in resp.aiter_lines():
if not line.strip():
continue
@@ -204,7 +252,7 @@ async def stream_chat_with_tools(
"stream": True,
"options": options,
"think": think,
"keep_alive": "2h",
"keep_alive": keep_alive_for(model),
}
if tools:
payload["tools"] = tools
@@ -215,22 +263,33 @@ async def stream_chat_with_tools(
f"{Config.OLLAMA_URL}/api/chat",
json=payload,
) as resp:
resp.raise_for_status()
await _raise_ollama_error(resp, model)
accumulated_tool_calls: list[dict] = []
# Silent-generation diagnostic: if Ollama reports non-zero eval_count
# but we never yielded any thinking/content/tool_calls, something
# in the frames isn't landing in a field we read. Capture the last
# few frames so we can see what Ollama actually sent.
yielded_anything = False
recent_frames: list[str] = []
async for line in resp.aiter_lines():
if not line.strip():
continue
if len(recent_frames) >= 5:
recent_frames.pop(0)
recent_frames.append(line[:500])
data = json.loads(line)
msg = data.get("message", {})
# Thinking chunks (qwen3 chain-of-thought, only when think=True)
thinking = msg.get("thinking", "")
if thinking:
yielded_anything = True
yield ChatChunk(type="thinking", content=thinking)
# Content chunks
chunk = msg.get("content", "")
if chunk:
yielded_anything = True
yield ChatChunk(type="content", content=chunk)
# Collect tool calls from any message (some models
@@ -246,13 +305,21 @@ async def stream_chat_with_tools(
len(accumulated_tool_calls),
json.dumps(accumulated_tool_calls)[:500],
)
yielded_anything = True
yield ChatChunk(type="tool_calls", tool_calls=accumulated_tool_calls)
else:
logger.debug("Ollama done with no tool calls")
eval_count = data.get("eval_count") or 0
if not yielded_anything and eval_count > 0:
logger.warning(
"Ollama silent generation: model=%s eval_count=%d but no "
"thinking/content/tool_calls were yielded. Last frames: %s",
model, eval_count, recent_frames,
)
yield ChatChunk(
type="done",
prompt_tokens=data.get("prompt_eval_count"),
output_tokens=data.get("eval_count"),
output_tokens=eval_count,
)
break
@@ -269,9 +336,17 @@ async def generate_completion(
num_ctx overrides the model's context window for this call only.
"""
last_exc: Exception | None = None
options: dict = {"num_predict": max_tokens}
if num_ctx is not None:
options["num_ctx"] = num_ctx
# Default num_ctx to Config.OLLAMA_NUM_CTX (matching stream_chat /
# stream_chat_with_tools). Without this, Ollama silently uses the model's
# default window (~4k on qwen3) and truncates anything longer. That is
# how the research pipeline's outline step kept falling back to a single
# monolith note: its 12-source prompt is ~6k tokens and was being chopped
# before the model ever saw it. Non-streaming callers must not inherit
# that footgun — if you truly want the model default, pass num_ctx=0.
options: dict = {
"num_predict": max_tokens,
"num_ctx": num_ctx if num_ctx is not None else Config.OLLAMA_NUM_CTX,
}
for attempt in range(3):
if attempt > 0:
delay = 3.0 * attempt
@@ -289,7 +364,7 @@ async def generate_completion(
"stream": False,
"think": False,
"options": options,
"keep_alive": "2h",
"keep_alive": keep_alive_for(model),
},
)
resp.raise_for_status()
@@ -510,18 +585,28 @@ async def build_context(
tool_lines = [
"You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.",
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes.",
"GROUNDING: When the user asks about their own data — tasks, notes, events, projects, news, anything stored in this system — call the relevant tool to see what actually exists before answering. Never assert facts about the user's data from memory, prior context, or assumption. If you are unsure whether something exists, check with a tool.",
"HONESTY WHEN EMPTY: If a tool returns empty results (no matching tasks, no events in the date range, no search hits, no notes found), tell the user plainly that nothing matched. Do not fabricate example items, do not invent plausible-sounding meetings or deadlines to fill the response, and do not hedge with generic suggestions dressed up as real data. A direct 'you don't have anything on your calendar today' is always better than an invented event.",
]
actions = [
"create_note (also creates tasks — set status='todo')", "update_note", "delete_note",
"read_note", "list_notes", "list_tasks", "log_work", "search_notes",
"create_project", "list_projects", "get_project", "update_project",
"search_projects", "create_milestone", "update_milestone", "list_milestones",
"save_person", "save_place", "create_list", "add_to_list", "clear_checked_items",
"set_rag_scope", "get_profile", "update_profile", "get_weather", "calculate",
"get_rss_items", "add_rss_feed", "read_article",
]
if has_caldav:
tool_lines[-1] = (
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, "
"create_event, list_events, search_events, update_event, delete_event, list_calendars."
)
actions.extend(["create_event", "list_events", "search_events", "update_event", "delete_event", "list_calendars"])
tool_lines.append(
"For calendar events, use ISO 8601 datetime format with the user's timezone offset (stated in context below). "
"Always include the UTC offset in datetime strings (e.g. 2026-09-30T14:00:00+01:00)."
)
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
if Config.searxng_enabled():
actions.extend(["search_web", "research_topic", "search_images"])
tool_lines.append(f"Available actions: {', '.join(actions)}.")
tool_lines.append(
"For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format. "
"Always include the UTC offset when creating events (user's timezone is stated in context below)."
@@ -538,6 +623,11 @@ async def build_context(
"Delete tools require an explicit user request. "
"Never proactively search notes or comment on absent context."
)
tool_lines.append(
"IMPORTANT: When creating tasks or notes, NEVER infer or guess a project name. "
"Only set the project parameter if the user explicitly names a project. "
"If the user says 'create a task to buy milk', do NOT assign it to a project."
)
tool_guidance = "\n".join(tool_lines)
static_block = (
@@ -595,7 +685,7 @@ async def build_context(
f"\n\n--- Active Workspace ---\n"
f"You are in the \"{wp.title}\" project workspace.\n"
f"All notes and tasks you create or update MUST belong to this project.\n"
f"Always pass project=\"{wp.title}\" when calling create_note or create_task.\n"
f"Always pass project=\"{wp.title}\" when calling create_note.\n"
f"--- End Active Workspace ---"
)
except Exception:
@@ -661,18 +751,27 @@ async def build_context(
orphan_only = rag_project_id is None
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
try:
from fabledassistant.services.embeddings import semantic_search_notes
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=effective_project_id,
orphan_only=orphan_only,
):
found_scored.append((score, note))
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
# Skip RAG auto-injection on the first turn of a seeded article discussion.
# The article body is already the sole context the user wants — pulling in
# unrelated orphan notes tricks the model into summarizing those instead.
# Follow-up turns keep RAG on because by then the user's own messages drive
# the query rather than the generic seed prompt.
from fabledassistant.services.article_context import ARTICLE_DISCUSS_SEED
_skip_rag_for_article_seed = user_message.strip() == ARTICLE_DISCUSS_SEED
if not found_scored:
if not _skip_rag_for_article_seed:
try:
from fabledassistant.services.embeddings import semantic_search_notes
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=effective_project_id,
orphan_only=orphan_only,
):
found_scored.append((score, note))
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
if not found_scored and not _skip_rag_for_article_seed:
keywords = _extract_keywords(user_message)
if keywords:
try:
+30
View File
@@ -22,6 +22,22 @@ def _normalize_tags(tags: list[str]) -> list[str]:
return out
async def _maybe_reactivate_project(project_id: int) -> None:
"""If a project is paused, reactivate it — activity indicates resumed work."""
from fabledassistant.models.project import Project
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project and project.status == "paused":
project.status = "active"
await session.commit()
logger.info("Auto-reactivated paused project %d (%s)", project_id, project.title)
except Exception:
logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True)
async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None:
"""Fire generate_project_summary() if the project summary is missing or >1h old."""
import asyncio
@@ -92,6 +108,7 @@ async def create_note(
await session.refresh(note)
if project_id is not None:
await _maybe_reactivate_project(project_id)
await _maybe_trigger_project_summary(user_id, project_id)
return note
@@ -119,6 +136,7 @@ async def list_notes(
milestone_ids: list[int] | None = None,
parent_id: int | None = None,
no_project: bool = False,
exclude_paused_projects: bool = False,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
@@ -194,6 +212,17 @@ async def list_notes(
query = query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.project_id.is_(None))
if exclude_paused_projects:
from fabledassistant.models.project import Project
paused_ids = (
select(Project.id)
.where(Project.user_id == user_id, Project.status == "paused")
.scalar_subquery()
)
paused_filter = or_(Note.project_id.is_(None), Note.project_id.not_in(paused_ids))
query = query.where(paused_filter)
count_query = count_query.where(paused_filter)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
@@ -284,6 +313,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
await create_version(user_id, note_id, old_body, old_title, old_tags)
if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
await _maybe_trigger_project_summary(user_id, note.project_id)
return note
@@ -103,7 +103,7 @@ async def send_password_reset_email(email: str, reset_url: str) -> None:
We received a request to reset your password. Click the button below to choose a new password.
</p>
<div style="text-align:center;margin:24px 0;">
<a href="{reset_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
<a href="{reset_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Reset Password
</a>
</div>
@@ -136,7 +136,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
Click the button below to create your account.
</p>
<div style="text-align:center;margin:24px 0;">
<a href="{invite_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
<a href="{invite_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Accept Invitation
</a>
</div>
+20 -3
View File
@@ -124,7 +124,7 @@ async def generate_project_summary(user_id: int, project_id: int) -> None:
from fabledassistant.services.settings import get_setting
messages = [{"role": "user", "content": prompt}]
bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
summary = await generate_completion(messages, model=bg_model, max_tokens=400)
summary = await generate_completion(messages, model=bg_model, max_tokens=400, num_ctx=2048)
if not summary:
return
@@ -141,13 +141,30 @@ async def generate_project_summary(user_id: int, project_id: int) -> None:
logger.debug("Failed to generate summary for project %d", project_id, exc_info=True)
# Cutoff: summaries older than this were generated by qwen2.5:3b, which
# produced broken output (token repetition, hallucinated topics, misspellings).
# Anything stamped before the switch to gemma3:4b on 2026-04-12 gets
# regenerated at startup so the whole project list ends up on the better model.
_BACKGROUND_MODEL_CUTOVER = datetime(2026, 4, 12, tzinfo=timezone.utc)
async def backfill_project_summaries() -> None:
"""Generate summaries for all projects missing auto_summary. Fire-and-forget."""
"""Generate summaries for projects missing or predating the gemma3:4b cutover.
Fire-and-forget: each summary runs as its own background task.
"""
import asyncio
from sqlalchemy import or_
try:
async with async_session() as session:
rows = (await session.execute(
select(Project.id, Project.user_id).where(Project.auto_summary.is_(None))
select(Project.id, Project.user_id).where(
or_(
Project.auto_summary.is_(None),
Project.summary_updated_at.is_(None),
Project.summary_updated_at < _BACKGROUND_MODEL_CUTOVER,
)
)
)).all()
for row in rows:
asyncio.create_task(generate_project_summary(row.user_id, row.id))
+33 -5
View File
@@ -62,11 +62,14 @@ def ensure_vapid_keys() -> None:
v = Vapid01()
v.generate_keys()
private_pem = v.private_key.private_bytes(
serialization.Encoding.PEM,
# pywebpush expects the private key as a base64url-encoded DER blob
# (passed to Vapid.from_string → from_der), NOT a PEM string.
private_der = v.private_key.private_bytes(
serialization.Encoding.DER,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
).decode()
)
private_b64 = base64.urlsafe_b64encode(private_der).rstrip(b"=").decode()
pub_bytes = v.public_key.public_bytes(
serialization.Encoding.X962,
@@ -76,15 +79,40 @@ def ensure_vapid_keys() -> None:
# Persist so they survive container restarts.
_VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
_VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_pem, "public_key": public_b64}))
_VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_b64, "public_key": public_b64}))
Config.VAPID_PRIVATE_KEY = private_pem
Config.VAPID_PRIVATE_KEY = private_b64
Config.VAPID_PUBLIC_KEY = public_b64
logger.info("Generated new VAPID keys and saved to %s", _VAPID_KEYS_FILE)
except Exception:
logger.warning("Failed to generate VAPID keys — push notifications will be unavailable", exc_info=True)
async def regenerate_vapid_keys() -> bool:
"""Delete existing VAPID keys, clear all push subscriptions, and generate a fresh pair.
All existing browser subscriptions are invalidated when keys rotate, so they
must be cleared — users will need to re-enable notifications.
"""
if _VAPID_KEYS_FILE.exists():
_VAPID_KEYS_FILE.unlink()
Config.VAPID_PRIVATE_KEY = ""
Config.VAPID_PUBLIC_KEY = ""
# Clear all push subscriptions — they are bound to the old public key.
async with async_session() as session:
await session.execute(delete(PushSubscription))
await session.commit()
ensure_vapid_keys()
enabled = vapid_enabled()
if enabled:
logger.info("VAPID keys regenerated successfully")
else:
logger.error("VAPID key regeneration failed")
return enabled
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
"""Upsert a push subscription by endpoint."""
endpoint = subscription_json.get("endpoint", "")
+127 -43
View File
@@ -9,7 +9,7 @@ import httpx
from fabledassistant.config import Config
from fabledassistant.services.llm import fetch_url_content, generate_completion, stream_chat
from fabledassistant.services.notes import create_note
from fabledassistant.services.notes import create_note, update_note
from fabledassistant.models.note import Note
logger = logging.getLogger(__name__)
@@ -35,8 +35,8 @@ def _build_sources_block(sources: list[dict]) -> str:
async def _generate_outline(topic: str, sources: list[dict], model: str) -> list[dict]:
"""Generate a topic outline from fetched research sources.
Returns a list of {"title": str, "focus": str} dicts (38 entries).
Returns [] on failure callers must fall back to single-note synthesis.
Returns a list of {"title": str, "focus": str} dicts (28 entries).
Retries once on failure before returning [] (callers fall back to single-note).
"""
import json as _json
@@ -61,23 +61,37 @@ async def _generate_outline(topic: str, sources: list[dict], model: str) -> list
"content": f"Topic: {topic}\n\nSources:\n{sources_block}",
},
]
try:
raw = await generate_completion(messages, model, max_tokens=400)
raw = raw.strip()
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)
idx = raw.find("[")
if idx >= 0:
parsed, _ = _json.JSONDecoder().raw_decode(raw[idx:])
if isinstance(parsed, list):
sections = [
s for s in parsed
if isinstance(s, dict) and s.get("title") and s.get("focus")
]
if len(sections) >= 3:
return sections[:8]
except Exception:
logger.warning("Outline generation failed for topic '%s'", topic, exc_info=True)
for attempt in range(2):
try:
# Pin num_ctx explicitly. The prompt carries up to 12 sources at
# 2000 chars each (~6k tokens of source material alone) plus the
# system prompt — well over Ollama's default model window on
# qwen3. Without this, Ollama silently truncates the prompt, the
# model can't see most of the sources, JSON parsing fails twice,
# and the pipeline falls back to a single monolith note
# (`research.py:251`). Do not remove even if `generate_completion`
# appears to default this — see the comment there.
raw = await generate_completion(
messages, model, max_tokens=400, num_ctx=16384
)
raw = raw.strip()
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)
idx = raw.find("[")
if idx >= 0:
parsed, _ = _json.JSONDecoder().raw_decode(raw[idx:])
if isinstance(parsed, list):
sections = [
s for s in parsed
if isinstance(s, dict) and s.get("title") and s.get("focus")
]
if len(sections) >= 2:
return sections[:8]
except Exception:
logger.warning(
"Outline generation attempt %d failed for topic '%s'",
attempt + 1, topic, exc_info=True,
)
return []
@@ -121,6 +135,55 @@ async def _synthesize_section(
return section_title, raw.strip()
async def _generate_executive_summary(
topic: str,
section_bodies: list[tuple[str, str]],
model: str,
) -> str:
"""Generate a 2-3 paragraph executive summary from completed section notes.
Args:
section_bodies: list of (title, body) pairs from the section notes.
Returns summary markdown (no heading — caller adds structure).
"""
sections_block = "\n\n".join(
f"### {title}\n{body[:1500]}" for title, body in section_bodies
)
messages = [
{
"role": "system",
"content": (
"You are a research summarizer. Given several completed research sections on a topic, "
"write a concise executive summary.\n\n"
"Requirements:\n"
"- 23 paragraphs of substantive prose (150300 words total)\n"
"- Cover the key findings and insights across all sections\n"
"- Highlight the most important or surprising takeaways\n"
"- Write so someone can decide which sections to read in detail\n"
"- Do NOT include headings, bullet points, or source citations\n"
"- Do NOT start with 'This research' or 'This document' — jump straight into the content"
),
},
{
"role": "user",
"content": f"Topic: {topic}\n\nSections:\n{sections_block}",
},
]
try:
# Pin num_ctx explicitly — see `_generate_outline` comment for the
# rationale. This prompt carries N sections × 1500 chars of section
# prose, which can easily exceed the default model window. Don't
# trust the `generate_completion` default to stick.
raw = await generate_completion(
messages, model, max_tokens=600, num_ctx=16384
)
return raw.strip()
except Exception:
logger.warning("Executive summary generation failed for '%s'", topic, exc_info=True)
return ""
async def run_research_pipeline(
topic: str,
user_id: int,
@@ -220,28 +283,17 @@ async def run_research_pipeline(
return_exceptions=True,
)
# Step 5: Create section notes sequentially
_status(f"Saving {len(outline)} notes...")
section_note_pairs: list[tuple[dict, Note]] = []
# Collect successful results for index + summary generation
section_results: list[tuple[dict, str, str]] = [] # (outline_entry, title, body)
for section, result in zip(outline, raw_results):
if isinstance(result, Exception):
logger.warning("Section synthesis failed for '%s': %s", section["title"], result)
continue
sec_title, sec_body = result
try:
note = await create_note(
user_id=user_id,
title=sec_title,
body=sec_body,
tags=["research"],
project_id=project_id,
)
section_note_pairs.append((section, note))
except Exception:
logger.warning("Failed to save section note '%s'", sec_title, exc_info=True)
section_results.append((section, sec_title, sec_body))
# All sections failed — fall back to single note
if not section_note_pairs:
if not section_results:
logger.warning("All section syntheses failed, falling back to single note for '%s'", topic)
_status("Synthesizing report (fallback)...")
title, body = await _synthesize_note(topic, synthesis_sources, model, buf=None)
@@ -250,20 +302,24 @@ async def run_research_pipeline(
)
return note
# Step 6: Create index note
# Step 5: Generate executive summary from section content
_status("Writing summary...")
executive_summary = await _generate_executive_summary(
topic, [(t, b) for _, t, b in section_results], model,
)
# Step 6: Create index note first (so section notes can reference it via parent_id)
from datetime import date as _date
index_lines = [
f"Research overview for **{topic}** — {_date.today().isoformat()}",
"",
f"Generated from {len(synthesis_sources)} web sources across {len(section_note_pairs)} sections.",
"",
"## Sections",
f"Generated from {len(synthesis_sources)} web sources across {len(section_results)} sections.",
"",
]
for section, note in section_note_pairs:
index_lines.append(f"- **{note.title}** — {section['focus']}")
index_lines += ["", "*Search for any section title to read it.*"]
if executive_summary:
index_lines += ["## Summary", "", executive_summary, ""]
index_lines += ["## Sections", ""]
# Placeholder — will be updated with real links after section notes are created
index_note = await create_note(
user_id=user_id,
title=f"Research: {topic}",
@@ -271,6 +327,34 @@ async def run_research_pipeline(
tags=["research", "research-index"],
project_id=project_id,
)
# Step 7: Create section notes with parent_id pointing to index
_status(f"Saving {len(section_results)} notes...")
section_note_pairs: list[tuple[dict, Note]] = []
for section, sec_title, sec_body in section_results:
try:
note = await create_note(
user_id=user_id,
title=sec_title,
body=sec_body,
tags=["research"],
project_id=project_id,
parent_id=index_note.id,
)
section_note_pairs.append((section, note))
except Exception:
logger.warning("Failed to save section note '%s'", sec_title, exc_info=True)
# Step 8: Update index note body with real links to section notes
for section, note in section_note_pairs:
index_lines.append(f"- [{note.title}](/notes/{note.id}) — {section['focus']}")
await update_note(
user_id=user_id,
note_id=index_note.id,
body="\n".join(index_lines),
)
logger.info(
"Research: created %d section notes + index id=%d for topic '%s'",
len(section_note_pairs), index_note.id, topic,
+33
View File
@@ -34,6 +34,34 @@ def _html_to_text(html: str) -> str:
return html
async def get_or_fetch_full_article(item: RssItem) -> str | None:
"""Return the full article body, fetching+caching on miss.
Checks ``item.content_full`` first — populated either by the enrichment
pass at feed-ingest time or by a previous discuss-click. On miss, fetches
via ``_fetch_full_article`` and writes through. Returns ``None`` only if
the fetch itself fails; ``item.content_full == ""`` is still a cache hit.
Callers must pass an RssItem attached to an open session if they want
the write-through to persist — otherwise the fetched text is returned
but the cache stays empty and the next click will re-fetch.
"""
if item.content_full is not None:
return item.content_full
if not item.url:
return None
text = await _fetch_full_article(item.url)
if text is None:
return None
async with async_session() as session:
fresh = await session.get(RssItem, item.id)
if fresh is not None:
fresh.content_full = text
fresh.content_fetched_at = datetime.now(timezone.utc)
await session.commit()
return text
async def _fetch_full_article(url: str) -> str | None:
"""Fetch a URL and extract its main article text via trafilatura.
@@ -209,6 +237,11 @@ async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
item = await session.get(RssItem, item_id)
if item:
item.content = full_text
# Populate the discuss-click cache too so the
# first click skips straight to the map-reduce
# step without re-fetching.
item.content_full = full_text
item.content_fetched_at = datetime.now(timezone.utc)
await session.commit()
await upsert_rss_item_embedding(
item_id, feed_user_id, item.title or "", item.content
+11 -3
View File
@@ -55,8 +55,12 @@ def stt_available() -> bool:
return _model is not None
async def transcribe(audio_bytes: bytes, mime_type: str = "audio/webm") -> str:
"""Transcribe audio bytes to text. Runs the model in a thread executor."""
async def transcribe(audio_bytes: bytes, mime_type: str = "audio/webm", initial_prompt: str | None = None) -> str:
"""Transcribe audio bytes to text. Runs the model in a thread executor.
initial_prompt: optional text to bias the model toward domain-specific vocabulary
(e.g. recent conversation context). Reduces mishearings like "gold" for "cold".
"""
if _model is None:
raise RuntimeError("STT model not loaded")
@@ -73,7 +77,11 @@ async def transcribe(audio_bytes: bytes, mime_type: str = "audio/webm") -> str:
f.write(audio_bytes)
f.flush()
t0 = time.monotonic()
segments, _ = _model.transcribe(f.name, beam_size=5) # type: ignore[union-attr]
segments, _ = _model.transcribe( # type: ignore[union-attr]
f.name,
beam_size=5,
initial_prompt=initial_prompt or None,
)
text = " ".join(seg.text.strip() for seg in segments).strip()
logger.debug("STT transcription took %.2fs", time.monotonic() - t0)
return text
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
"""Tool registry package.
Importing this package loads all tool modules (triggering ``@tool``
decorator registration) and re-exports the public API that the rest
of the app depends on.
"""
# Import every tool module so their @tool decorators run at import time.
# Order does not matter — registration is additive.
from fabledassistant.services.tools import ( # noqa: F401
calendar,
entities,
notes,
profile,
projects,
rag,
rss,
tasks,
utility,
weather,
web,
)
from fabledassistant.services.tools._registry import (
execute_tool,
get_briefing_tools,
get_tools_for_user,
)
__all__ = [
"execute_tool",
"get_briefing_tools",
"get_tools_for_user",
]
@@ -0,0 +1,124 @@
"""Shared utilities used across tool modules."""
from __future__ import annotations
import asyncio
import logging
import re
from datetime import date, datetime
from difflib import SequenceMatcher
logger = logging.getLogger(__name__)
_PUNCT_RE = re.compile(r"[^\w\s]")
def schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> None:
"""Fire-and-forget: update the embedding for a note after it's created/modified."""
from fabledassistant.services.embeddings import upsert_note_embedding
text = f"{title}\n{body}".strip() if body else (title or "")
if text:
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
async def resolve_project(user_id: int, project_name: str):
"""Exact-then-fuzzy project lookup. Returns the Project or None.
Resolution order:
1. Exact title match (case-insensitive via DB)
2. project_name is a substring of an existing title
3. Existing title is a substring of project_name
4. SequenceMatcher ratio >= 0.55
"""
from fabledassistant.services.projects import get_project_by_title, list_projects
proj = await get_project_by_title(user_id, project_name)
if proj is not None:
return proj
needle = project_name.lower().strip()
all_p = await list_projects(user_id)
for p in all_p:
haystack = p.title.lower().strip()
if needle in haystack or haystack in needle:
return p
best, best_r = None, 0.0
for p in all_p:
r = SequenceMatcher(None, needle, p.title.lower().strip()).ratio()
if r >= 0.55 and r > best_r:
best, best_r = p, r
return best
def parse_due_date(value: str | None) -> date | None:
"""Parse a due date string, returning None on failure."""
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except (ValueError, TypeError):
logger.warning("Invalid due_date format: %s", value)
return None
def fuzzy_title_match(title: str, candidates, threshold: float = 0.82):
"""Return (best_match, ratio) if any candidate's title is similar enough.
Uses SequenceMatcher ratio. Threshold 0.82 catches near-duplicates like
"Game Premise" / "Game Premise Notes" while leaving clearly different
titles alone. Returns (None, 0.0) when no candidate meets the threshold.
"""
needle = title.lower().strip()
best, best_r = None, 0.0
for c in candidates:
r = SequenceMatcher(None, needle, c.title.lower().strip()).ratio()
if r >= threshold and r > best_r:
best, best_r = c, r
return best, best_r
async def check_duplicate(
user_id: int,
title: str,
body: str,
is_task: bool,
confirmed: bool,
) -> dict | None:
"""Check for exact, fuzzy, and semantic duplicates. Returns error dict or None."""
from fabledassistant.services.notes import list_notes
item_label = "task" if is_task else "note"
existing, _ = await list_notes(user_id=user_id, q=title, is_task=is_task, limit=1)
exact = next((n for n in existing if n.title.lower() == title.lower()), None)
if exact is not None:
return {
"success": False,
"error": f"A {item_label} titled '{title}' already exists (id: {exact.id}). Use update_note to modify it instead of creating a duplicate.",
}
clean_q = _PUNCT_RE.sub(" ", title).strip()
candidates, _ = await list_notes(user_id=user_id, q=clean_q, is_task=is_task, limit=20)
near, ratio = fuzzy_title_match(title, candidates)
if near is not None:
return {
"success": False,
"requires_confirmation": True,
"similar_note": {"id": near.id, "title": near.title},
"error": f"A {item_label} with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry.",
}
if not confirmed and len(body.strip()) >= 80:
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
sem_query = f"{title}\n{body}".strip()
sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.90, is_task=is_task)
if sem_hits:
best_score, best_note = sem_hits[0]
return {
"success": False,
"requires_confirmation": True,
"similar_note": {"id": best_note.id, "title": best_note.title},
"error": f"A {item_label} with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry.",
}
return None
@@ -0,0 +1,133 @@
"""Decorator-based tool registry.
Each tool is registered via ``@tool(...)`` which captures the OpenAI-style
function schema and handler in one place. ``get_tools_for_user`` and
``execute_tool`` are the public API consumed by the rest of the app.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Coroutine
from fabledassistant.config import Config
from fabledassistant.services.caldav import is_caldav_configured
logger = logging.getLogger(__name__)
type ToolHandler = Callable[..., Coroutine[Any, Any, dict]]
@dataclass(frozen=True, slots=True)
class ToolDef:
name: str
description: str
parameters: dict
handler: ToolHandler
read_only: bool = False
briefing: bool = False
requires: str | None = None
required_params: list[str] = field(default_factory=list)
def schema(self) -> dict:
"""Return the OpenAI function-calling schema dict."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": self.parameters,
**({"required": self.required_params} if self.required_params else {}),
},
},
}
_REGISTRY: dict[str, ToolDef] = {}
def tool(
*,
name: str,
description: str,
parameters: dict | None = None,
required: list[str] | None = None,
read_only: bool = False,
briefing: bool = False,
requires: str | None = None,
) -> Callable[[ToolHandler], ToolHandler]:
"""Register an async tool handler with its schema metadata."""
def decorator(fn: ToolHandler) -> ToolHandler:
if name in _REGISTRY:
raise ValueError(f"Duplicate tool registration: {name}")
_REGISTRY[name] = ToolDef(
name=name,
description=description,
parameters=parameters or {},
handler=fn,
read_only=read_only,
briefing=briefing,
requires=requires,
required_params=required or [],
)
return fn
return decorator
async def _check_requires(user_id: int, requires: str) -> bool:
if requires == "caldav":
return await is_caldav_configured(user_id)
if requires == "searxng":
return Config.searxng_enabled()
return True
async def get_tools_for_user(user_id: int) -> list[dict]:
"""Build the tool schema list for a user based on configured integrations."""
tools: list[dict] = []
for td in _REGISTRY.values():
if td.requires and not await _check_requires(user_id, td.requires):
continue
tools.append(td.schema())
logger.debug("User %d: %d tools available", user_id, len(tools))
return tools
async def get_briefing_tools(user_id: int) -> list[dict]:
"""Return only the tool schemas marked ``briefing=True``."""
all_tools = await get_tools_for_user(user_id)
names = {td.name for td in _REGISTRY.values() if td.briefing}
filtered = [t for t in all_tools if t["function"]["name"] in names]
logger.debug(
"Briefing tools for user %d: %d of %d selected",
user_id, len(filtered), len(all_tools),
)
return filtered
async def execute_tool(
user_id: int,
tool_name: str,
arguments: dict,
conv_id: int | None = None,
workspace_project_id: int | None = None,
) -> dict:
"""Execute a tool call and return the result."""
td = _REGISTRY.get(tool_name)
if td is None:
return {"success": False, "error": f"Unknown tool: {tool_name}"}
try:
return await td.handler(
user_id=user_id,
arguments=arguments,
conv_id=conv_id,
workspace_project_id=workspace_project_id,
)
except Exception as e:
logger.exception("Tool execution failed: %s", tool_name)
return {"success": False, "error": str(e)}
@@ -0,0 +1,250 @@
"""Calendar and event tools."""
from __future__ import annotations
from datetime import datetime, timezone
from fabledassistant.services.events import (
create_event as events_create_event,
delete_event as events_delete_event,
find_events_by_query,
list_events as events_list_events,
search_events as events_search_events,
update_event as events_update_event,
)
from fabledassistant.services.tools._helpers import resolve_project
from fabledassistant.services.tools._registry import tool
from fabledassistant.services.tz import get_user_tz
async def _parse_datetime_in_user_tz(
user_id: int, value: str
) -> tuple[datetime, bool]:
"""Parse a date/datetime string from the model into a UTC-aware datetime.
Naive inputs are interpreted in the **user's local timezone** and then
converted to UTC for storage. Never default to UTC for naive inputs
that's how all-day events landed on the wrong day for non-UTC users.
Returns ``(utc_datetime, was_date_only)``.
"""
was_date_only = "T" not in value and " " not in value
if was_date_only:
value = f"{value}T00:00:00"
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
user_tz = await get_user_tz(user_id)
dt = dt.replace(tzinfo=user_tz)
return dt.astimezone(timezone.utc), was_date_only
@tool(
name="create_event",
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event. Pass dates and datetimes in the user's local time — a bare date like '2026-09-30' is interpreted as that day in the user's configured timezone.",
parameters={
"title": {"type": "string", "description": "A descriptive event title"},
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:MM) in the user's local time. Include a timezone offset only if the user explicitly mentions one."},
"end": {"type": "string", "description": "Optional end date or datetime in the user's local time"},
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)"},
"description": {"type": "string", "description": "Optional event description"},
"location": {"type": "string", "description": "Optional event location"},
"color": {"type": "string", "description": "Optional hex color for the event (e.g. '#6366f1')"},
"all_day": {"type": "boolean", "description": "True for all-day events (birthdays, holidays, deadlines)"},
"recurrence": {"type": "string", "description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)"},
"reminder_minutes": {"type": "integer", "description": "Optional reminder N minutes before the event (e.g. 30 for 30 minutes before)"},
"attendees": {"type": "array", "items": {"type": "string"}, "description": "Optional list of attendee email addresses"},
"calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."},
"project": {"type": "string", "description": "Optional project name to associate this event with"},
},
required=["title", "start"],
)
async def create_event_tool(*, user_id, arguments, **_ctx):
start_str = arguments["start"]
end_str = arguments.get("end")
all_day = arguments.get("all_day", False)
try:
# Naive dates/datetimes are interpreted in the user's local
# timezone, not UTC. Storing UTC for naive inputs caused all-day
# events to land on the previous day for negative-offset users.
start_dt, start_was_date_only = await _parse_datetime_in_user_tz(
user_id, start_str
)
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
if start_was_date_only:
all_day = True
end_dt = None
if end_str:
try:
end_dt, _ = await _parse_datetime_in_user_tz(user_id, end_str)
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
project_id = None
project_name = arguments.get("project")
if project_name:
proj = await resolve_project(user_id, project_name)
if proj:
project_id = proj.id
event = await events_create_event(
user_id=user_id,
title=arguments.get("title", "Untitled Event"),
start_dt=start_dt,
end_dt=end_dt,
all_day=all_day,
description=arguments.get("description") or "",
location=arguments.get("location") or "",
color=arguments.get("color") or "",
recurrence=arguments.get("recurrence"),
project_id=project_id,
duration=arguments.get("duration"),
reminder_minutes=arguments.get("reminder_minutes"),
attendees=arguments.get("attendees"),
calendar_name=arguments.get("calendar_name"),
)
return {"success": True, "type": "event", "data": event.to_dict()}
@tool(
name="list_events",
description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Pass plain local dates (YYYY-MM-DD) — the server interprets them in the user's timezone and expands to a full local day.",
parameters={
"date_from": {"type": "string", "description": "Start of range as a local date (YYYY-MM-DD) or local datetime. Interpreted in the user's timezone."},
"date_to": {"type": "string", "description": "End of range as a local date (YYYY-MM-DD) or local datetime. A bare date is expanded to 23:59:59 local."},
},
required=["date_from", "date_to"],
read_only=True,
briefing=True,
)
async def list_events_tool(*, user_id, arguments, **_ctx):
# Bare local dates are expanded to a full local day in the user's TZ:
# date_from → 00:00 local, date_to → 23:59:59 local, both converted to
# UTC before the DB query. Previously the tool description told the
# model to pass UTC ranges, which missed events for non-UTC users.
try:
date_from, _ = await _parse_datetime_in_user_tz(
user_id, arguments["date_from"]
)
date_to_str = arguments["date_to"]
if "T" not in date_to_str and " " not in date_to_str:
date_to_str = f"{date_to_str}T23:59:59"
date_to, _ = await _parse_datetime_in_user_tz(user_id, date_to_str)
except (ValueError, TypeError, KeyError) as exc:
return {"success": False, "error": f"Invalid date range: {exc}"}
events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to)
return {
"success": True,
"type": "events",
"data": {"count": len(events), "events": events},
}
@tool(
name="search_events",
description="Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.",
parameters={
"query": {"type": "string", "description": "Search keyword to match against event titles, locations, and descriptions"},
"include_past": {"type": "boolean", "description": "Set to true to include past events in results (default: future events only)"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def search_events_tool(*, user_id, arguments, **_ctx):
events = await events_search_events(
user_id=user_id,
query=arguments.get("query", ""),
include_past=arguments.get("include_past", False),
)
return {
"success": True,
"type": "events",
"data": {
"query": arguments.get("query", ""),
"count": len(events),
"events": [e.to_dict() for e in events],
},
}
@tool(
name="update_event",
description="Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.",
parameters={
"query": {"type": "string", "description": "Search term to find the event to update (matches against title)"},
"title": {"type": "string", "description": "New title for the event"},
"start": {"type": "string", "description": "New start datetime in ISO 8601 format"},
"end": {"type": "string", "description": "New end datetime in ISO 8601 format"},
"all_day": {"type": "boolean", "description": "Whether the event is all-day"},
"description": {"type": "string", "description": "New event description"},
"location": {"type": "string", "description": "New event location"},
"color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"},
"recurrence": {"type": "string", "description": "New iCalendar RRULE"},
"reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."},
},
required=["query"],
)
async def update_event_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return {"success": False, "error": f"No event found matching '{query}'."}
event_to_update = matches[0]
fields: dict = {}
for str_field in ("title", "description", "location", "color", "recurrence"):
if arguments.get(str_field) is not None:
fields[str_field] = arguments[str_field]
if arguments.get("all_day") is not None:
fields["all_day"] = arguments["all_day"]
if "reminder_minutes" in arguments:
rm = arguments["reminder_minutes"]
fields["reminder_minutes"] = None if rm == 0 else rm
for dt_field, key in (("start_dt", "start"), ("end_dt", "end")):
val = arguments.get(key)
if val:
try:
# Naive datetimes are user-local, not UTC — see
# ``_parse_datetime_in_user_tz`` docstring.
dt, _ = await _parse_datetime_in_user_tz(user_id, val)
fields[dt_field] = dt
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields)
if updated is None:
return {"success": False, "error": "Event not found or update failed."}
return {"success": True, "type": "event_updated", "data": updated.to_dict()}
@tool(
name="delete_event",
description="Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
parameters={
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title)"},
},
required=["query"],
)
async def delete_event_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return {"success": False, "error": f"No event found matching '{query}'."}
event_to_delete = matches[0]
await events_delete_event(user_id=user_id, event_id=event_to_delete.id)
return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}}
@tool(
name="list_calendars",
description="List all available calendars. Use this when the user asks which calendars they have.",
parameters={},
read_only=True,
requires="caldav",
)
async def list_calendars_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.caldav import list_calendars
calendars = await list_calendars(user_id=user_id)
return {
"success": True,
"type": "calendars",
"data": {"count": len(calendars), "calendars": calendars},
}
@@ -0,0 +1,231 @@
"""Entity tools: people, places, and lists."""
from __future__ import annotations
from fabledassistant.services.notes import create_note, list_notes, update_note
from fabledassistant.services.tools._helpers import schedule_embedding
from fabledassistant.services.tools._registry import tool
def _build_person_body(meta: dict, extra_notes: str | None = None) -> str:
"""Build markdown body from person metadata."""
lines = []
for key, label in (("relationship", "Relationship"), ("phone", "Phone"), ("email", "Email"), ("birthday", "Birthday"), ("address", "Address")):
if meta.get(key):
lines.append(f"**{label}:** {meta[key]}")
if extra_notes:
lines.append(f"\n{extra_notes}")
return "\n".join(lines)
def _build_place_body(meta: dict, extra_notes: str | None = None) -> str:
"""Build markdown body from place metadata."""
lines = []
for key, label in (("address", "Address"), ("phone", "Phone"), ("hours", "Hours"), ("url", "Website")):
if meta.get(key):
lines.append(f"**{label}:** {meta[key]}")
if extra_notes:
lines.append(f"\n{extra_notes}")
return "\n".join(lines)
_PERSON_FIELDS = ("relationship", "phone", "email", "birthday", "address")
_PLACE_FIELDS = ("address", "phone", "hours", "url")
@tool(
name="save_person",
description=(
"Save or update a person in the user's knowledge base. Creates a new entry if the "
"person doesn't exist, or updates an existing one. Use when the user introduces "
"someone by name or adds/corrects details about a known person."
),
parameters={
"name": {"type": "string", "description": "Full name of the person (used to find existing entry or as new entry title)"},
"relationship": {"type": "string", "description": "Relationship to the user (e.g. daughter, dentist, colleague)"},
"phone": {"type": "string", "description": "Phone number"},
"email": {"type": "string", "description": "Email address"},
"birthday": {"type": "string", "description": "Birthday in YYYY-MM-DD format"},
"address": {"type": "string", "description": "Home or mailing address"},
"notes": {"type": "string", "description": "Any additional free-form notes about this person"},
},
required=["name"],
)
async def save_person_tool(*, user_id, arguments, **_ctx):
name = str(arguments.get("name", "")).strip()
if not name:
return {"success": False, "error": "name is required"}
existing, _ = await list_notes(user_id=user_id, q=name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "person"), None)
if target is not None:
meta = dict(target.entity_meta or {})
for field in _PERSON_FIELDS:
val = arguments.get(field)
if val is not None:
meta[field] = str(val)
extra_notes = arguments.get("notes")
body = _build_person_body(meta, extra_notes)
updated = await update_note(user_id=user_id, note_id=target.id, body=body, entity_meta=meta)
if updated is None:
return {"success": False, "error": "Failed to update person."}
schedule_embedding(target.id, user_id, target.title, body)
return {"success": True, "type": "person_updated", "data": {"id": target.id, "name": target.title}}
meta = {}
for field in _PERSON_FIELDS:
val = arguments.get(field)
if val:
meta[field] = str(val)
body = _build_person_body(meta, arguments.get("notes", ""))
note = await create_note(
user_id=user_id, title=name, body=body,
tags=["person"], note_type="person", entity_meta=meta,
)
schedule_embedding(note.id, user_id, name, note.body)
return {"success": True, "type": "person", "data": {"id": note.id, "name": name}}
@tool(
name="save_place",
description=(
"Save or update a named location in the user's knowledge base. Creates a new entry "
"if the place doesn't exist, or updates an existing one. Use when the user wants to "
"remember or correct details about a place (business, venue, address)."
),
parameters={
"name": {"type": "string", "description": "Name of the place (e.g. 'Dr. Smith Dental', 'Costco')"},
"address": {"type": "string", "description": "Street address"},
"phone": {"type": "string", "description": "Phone number"},
"hours": {"type": "string", "description": "Opening hours (e.g. 'Mon-Fri 9am-5pm')"},
"url": {"type": "string", "description": "Website URL"},
"notes": {"type": "string", "description": "Any additional free-form notes about this place"},
},
required=["name"],
)
async def save_place_tool(*, user_id, arguments, **_ctx):
name = str(arguments.get("name", "")).strip()
if not name:
return {"success": False, "error": "name is required"}
existing, _ = await list_notes(user_id=user_id, q=name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "place"), None)
if target is not None:
meta = dict(target.entity_meta or {})
for field in _PLACE_FIELDS:
val = arguments.get(field)
if val is not None:
meta[field] = str(val)
extra_notes = arguments.get("notes")
body = _build_place_body(meta, extra_notes)
updated = await update_note(user_id=user_id, note_id=target.id, body=body, entity_meta=meta)
if updated is None:
return {"success": False, "error": "Failed to update place."}
schedule_embedding(target.id, user_id, target.title, body)
return {"success": True, "type": "place_updated", "data": {"id": target.id, "name": target.title}}
meta = {}
for field in _PLACE_FIELDS:
val = arguments.get(field)
if val:
meta[field] = str(val)
body = _build_place_body(meta, arguments.get("notes", ""))
note = await create_note(
user_id=user_id, title=name, body=body,
tags=["place"], note_type="place", entity_meta=meta,
)
schedule_embedding(note.id, user_id, name, note.body)
return {"success": True, "type": "place", "data": {"id": note.id, "name": name}}
@tool(
name="create_list",
description="Create a named list (shopping list, to-do list, packing list, etc.). Items are stored as a markdown task list. Use add_to_list to add items to an existing list.",
parameters={
"name": {"type": "string", "description": "List name (e.g. 'Grocery List', 'Hardware Store')"},
"items": {"type": "array", "items": {"type": "string"}, "description": "Initial list items (unchecked)"},
"store": {"type": "string", "description": "Optional associated store or place name"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags for the list"},
},
required=["name"],
)
async def create_list_tool(*, user_id, arguments, **_ctx):
name = str(arguments.get("name", "")).strip()
if not name:
return {"success": False, "error": "name is required"}
items = arguments.get("items") or []
if not isinstance(items, list):
items = []
body = "\n".join(f"- [ ] {item}" for item in items if str(item).strip())
meta = {}
store = arguments.get("store")
if store:
meta["store"] = str(store)
tags = arguments.get("tags") or []
if not isinstance(tags, list):
tags = []
note = await create_note(
user_id=user_id, title=name, body=body,
tags=tags, note_type="list", entity_meta=meta,
)
schedule_embedding(note.id, user_id, name, body)
return {"success": True, "type": "list", "data": {"id": note.id, "name": name, "item_count": len(items)}}
@tool(
name="add_to_list",
description="Add one or more items to an existing list. Items are appended as unchecked entries.",
parameters={
"list_name": {"type": "string", "description": "Name of the list to add items to"},
"items": {"type": "array", "items": {"type": "string"}, "description": "Items to add"},
},
required=["list_name", "items"],
)
async def add_to_list_tool(*, user_id, arguments, **_ctx):
list_name = str(arguments.get("list_name", "")).strip()
items = arguments.get("items") or []
if not list_name:
return {"success": False, "error": "list_name is required"}
if not isinstance(items, list) or not items:
return {"success": False, "error": "items must be a non-empty array"}
existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None)
if target is None:
target = next((n for n in existing if n.note_type == "list"), None)
if target is None:
return {"success": False, "error": f"No list named '{list_name}' found. Use create_list to create it first."}
new_lines = "\n".join(f"- [ ] {item}" for item in items if str(item).strip())
existing_body = (target.body or "").rstrip()
new_body = f"{existing_body}\n{new_lines}".lstrip()
await update_note(user_id=user_id, note_id=target.id, body=new_body)
schedule_embedding(target.id, user_id, target.title, new_body)
return {"success": True, "type": "list_updated", "data": {"id": target.id, "name": target.title, "added": len(items)}}
@tool(
name="clear_checked_items",
description="Remove all checked/completed items from a list, keeping only unchecked items.",
parameters={
"list_name": {"type": "string", "description": "Name of the list to clear"},
},
required=["list_name"],
)
async def clear_checked_items_tool(*, user_id, arguments, **_ctx):
import re as _re
list_name = str(arguments.get("list_name", "")).strip()
if not list_name:
return {"success": False, "error": "list_name is required"}
existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None)
if target is None:
target = next((n for n in existing if n.note_type == "list"), None)
if target is None:
return {"success": False, "error": f"No list named '{list_name}' found."}
body = target.body or ""
cleaned = _re.sub(r"^- \[[xX]\] .+\n?", "", body, flags=_re.MULTILINE).strip()
await update_note(user_id=user_id, note_id=target.id, body=cleaned)
schedule_embedding(target.id, user_id, target.title, cleaned)
return {"success": True, "type": "list_cleared", "data": {"id": target.id, "name": target.title}}
+420
View File
@@ -0,0 +1,420 @@
"""Note tools: create, update, get, list, delete, search."""
from __future__ import annotations
import logging
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
from fabledassistant.services.tag_suggestions import suggest_tags
from fabledassistant.services.tools._helpers import (
check_duplicate,
parse_due_date,
resolve_project,
schedule_embedding,
)
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
@tool(
name="create_note",
description=(
"Create a new note or task. "
"For a knowledge note, omit the status field. "
"For an actionable task (todo, reminder, action item), set status to 'todo'. "
"Use this whenever the user asks to write down, save, record, or add a task/todo."
),
parameters={
"title": {"type": "string", "description": "The title"},
"body": {"type": "string", "description": "Content in markdown"},
"tags": {"type": "array", "items": {"type": "string"}, "description": 'Tags (without # prefix, hyphens for multi-word: ["science-fiction", "story/idea"]). Do NOT embed #tags in the body.'},
"project": {"type": "string", "description": "Optional project name. Only set this if the user explicitly named a project. Do NOT infer a project from the content or context."},
"status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "Set to 'todo' to create a task. Omit entirely for a knowledge note."},
"due_date": {"type": "string", "description": "Due date in YYYY-MM-DD format (tasks only)"},
"priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "Priority level (tasks only, default: none)"},
"parent_task": {"type": "string", "description": "Title of a parent task to make this a sub-task of"},
"milestone": {"type": "string", "description": "Milestone title within the project to assign to"},
"recurrence_rule": {"type": "object", "description": 'Recurrence rule (tasks only). Interval form: {"type":"interval","every":3,"unit":"month"} (units: day/week/month/year). Calendar form: {"type":"calendar","unit":"month","day_of_month":1}. Annual: add "month":6.'},
"confirmed": {"type": "boolean", "description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing item."},
},
required=["title"],
)
async def create_note_tool(*, user_id, arguments, **_ctx):
title = arguments.get("title", "Untitled")
body = arguments.get("body", "")
tags = arguments.get("tags", [])
if not isinstance(title, str):
return {"success": False, "error": "title must be a string. Call create_note once per item."}
if not isinstance(body, str):
body = ""
if not isinstance(tags, list):
tags = []
is_task = "status" in arguments and arguments["status"] is not None
status = arguments.get("status", "todo") if is_task else None
project_name = arguments.get("project")
milestone_name = arguments.get("milestone")
parent_task_name = arguments.get("parent_task")
project_id = None
milestone_id = None
if project_name:
proj = await resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
project_id = proj.id
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt
ms = await _gmbt(user_id, proj.id, milestone_name)
if ms is None:
return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."}
milestone_id = ms.id
dup = await check_duplicate(user_id, title, body, is_task=is_task, confirmed=bool(arguments.get("confirmed")))
if dup is not None:
return dup
note = await create_note(
user_id=user_id,
title=title,
body=body,
tags=tags,
status=status,
priority=arguments.get("priority", "none") if is_task else None,
due_date=parse_due_date(arguments.get("due_date")) if is_task else None,
recurrence_rule=arguments.get("recurrence_rule") if is_task else None,
project_id=project_id,
milestone_id=milestone_id,
)
if parent_task_name:
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
if parent_notes:
note = await update_note(user_id, note.id, parent_id=parent_notes[0].id)
suggested = await suggest_tags(user_id, title, body)
schedule_embedding(note.id, user_id, title, body)
if is_task:
return {
"success": True,
"type": "task",
"data": {
"id": note.id,
"title": note.title,
"status": note.status,
"priority": note.priority,
"due_date": str(note.due_date) if note.due_date else None,
"project_id": note.project_id,
"milestone_id": note.milestone_id,
"parent_id": note.parent_id,
},
"suggested_tags": suggested,
}
return {
"success": True,
"type": "note",
"data": {"id": note.id, "title": note.title, "project_id": note.project_id},
"suggested_tags": suggested,
}
@tool(
name="update_note",
description="Update an existing note or task — content, title, status, priority, or due date. Use for edits, marking tasks done, changing priority. Never use create_note for existing notes.",
parameters={
"query": {"type": "string", "description": "Title or keyword to find the note or task to update"},
"body": {"type": "string", "description": "New note content in markdown (omit if only updating task fields)"},
"title": {"type": "string", "description": "Optional new title"},
"mode": {"type": "string", "enum": ["replace", "append"], "description": "How to apply the new body: 'replace' overwrites existing content (default), 'append' adds after existing content"},
"status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "New task status. Use to mark a task done, start it, cancel it, etc."},
"priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "New task priority"},
"due_date": {"type": "string", "description": "New due date in YYYY-MM-DD format"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply (see tag_mode for how they're applied)"},
"tag_mode": {"type": "string", "enum": ["add", "remove", "replace"], "description": "'add' appends tags without duplicates (default), 'remove' removes listed tags, 'replace' sets tags to exactly this list (destructive — avoid unless explicitly requested)"},
"project": {"type": "string", "description": "Optional project name to assign this note/task to (set to empty string to remove project)"},
"milestone": {"type": "string", "description": "Optional milestone title within the project to assign this task to (set to empty string to remove milestone)"},
"recurrence_rule": {"type": "object", "description": 'Set or update recurrence. Interval form: {"type":"interval","every":3,"unit":"month"}. Calendar form: {"type":"calendar","unit":"month","day_of_month":1}. Pass null to remove.'},
},
required=["query"],
)
async def update_note_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
new_body = arguments.get("body", "")
new_title = arguments.get("title")
mode = arguments.get("mode", "replace")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=5)
note_only = [n for n in notes if n.status is None]
candidates = note_only or notes
if not candidates:
return {"success": False, "error": f"No note found matching '{query}'."}
note = candidates[0]
update_fields: dict = {}
if new_title:
update_fields["title"] = new_title
if new_body:
if mode == "append" and note.body:
update_fields["body"] = note.body + "\n\n" + new_body
else:
update_fields["body"] = new_body
if "status" in arguments:
update_fields["status"] = arguments["status"]
if "priority" in arguments:
update_fields["priority"] = arguments["priority"]
if "due_date" in arguments:
update_fields["due_date"] = parse_due_date(arguments["due_date"])
if "tags" in arguments:
new_tags = arguments["tags"]
if isinstance(new_tags, list):
tag_mode = arguments.get("tag_mode", "add")
if tag_mode == "add":
existing = list(note.tags or [])
for t in new_tags:
if t not in existing:
existing.append(t)
update_fields["tags"] = existing
elif tag_mode == "remove":
existing = list(note.tags or [])
update_fields["tags"] = [t for t in existing if t not in new_tags]
else:
update_fields["tags"] = new_tags
if "project" in arguments:
project_name = arguments["project"]
if project_name:
proj = await resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."}
update_fields["project_id"] = proj.id
else:
update_fields["project_id"] = None
update_fields["milestone_id"] = None
if "milestone" in arguments:
milestone_name = arguments["milestone"]
if milestone_name:
ref_project_id = update_fields.get("project_id") or note.project_id
if ref_project_id is None:
return {"success": False, "error": "Cannot assign a milestone without a project. Set project first."}
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt_upd
ms = await _gmbt_upd(user_id, ref_project_id, milestone_name)
if ms is None:
return {"success": False, "error": f"Milestone '{milestone_name}' not found. Use list_milestones to see available milestones."}
update_fields["milestone_id"] = ms.id
else:
update_fields["milestone_id"] = None
updated = await update_note(user_id, note.id, **update_fields)
if updated is None:
return {"success": False, "error": "Failed to update note."}
suggested = await suggest_tags(user_id, updated.title, updated.body or "")
schedule_embedding(updated.id, user_id, updated.title, updated.body or "")
item_type = "task" if updated.status is not None else "note"
return {
"success": True,
"type": "note_updated",
"updated": f"{item_type} '{updated.title}' (id: {updated.id})",
"data": {
"id": updated.id,
"title": updated.title,
"item_type": item_type,
"status": updated.status,
"tags": updated.tags or [],
"project_id": updated.project_id,
},
"suggested_tags": suggested,
}
@tool(
name="search_notes",
description="Find notes or tasks by meaning. Returns a ranked list of matches with short previews. Use this when looking for items on a topic but you don't know the exact title. For the full body of a known item, use read_note instead.",
parameters={
"query": {"type": "string", "description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords"},
"type": {"type": "string", "enum": ["note", "task"], "description": "Restrict results to only notes or only tasks. Omit to search both."},
"project": {"type": "string", "description": "Optional project name to restrict search to notes in that project"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def search_notes_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
type_filter = arguments.get("type")
project_name = arguments.get("project")
is_task: bool | None = None
if type_filter == "note":
is_task = False
elif type_filter == "task":
is_task = True
search_project_id: int | None = None
if project_name:
proj = await resolve_project(user_id, project_name)
if proj:
search_project_id = proj.id
results_map: dict[int, dict] = {}
try:
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
sem_results = await _ssn(
user_id, query, limit=8, threshold=0.40,
project_id=search_project_id,
)
for score, n in sem_results:
n_is_task = n.status is not None
if is_task is True and not n_is_task:
continue
if is_task is False and n_is_task:
continue
results_map[n.id] = {
"id": n.id,
"title": n.title,
"type": "task" if n_is_task else "note",
"status": n.status,
"relevance": f"{round(score * 100)}%",
"preview": (n.body[:300] + "\u2026") if n.body and len(n.body) > 300 else (n.body or ""),
}
logger.debug("search_notes semantic: %d results for %r", len(results_map), query)
except Exception:
logger.debug("Semantic search unavailable in search_notes, using keyword only", exc_info=True)
try:
kw_notes, _ = await list_notes(
user_id=user_id, q=query, is_task=is_task,
project_id=search_project_id, limit=8,
)
for n in kw_notes:
if n.id not in results_map:
results_map[n.id] = {
"id": n.id,
"title": n.title,
"type": "task" if n.status is not None else "note",
"status": n.status,
"relevance": "keyword",
"preview": (n.body[:300] + "\u2026") if n.body and len(n.body) > 300 else (n.body or ""),
}
except Exception:
logger.warning("Keyword fallback in search_notes failed", exc_info=True)
results = list(results_map.values())[:8]
return {
"success": True,
"type": "search",
"data": {"query": query, "count": len(results), "results": results},
}
@tool(
name="read_note",
description="Read the full content of one specific note or task. Use when you know which item you want and need its complete body text. For discovering items by topic, use search_notes instead.",
parameters={
"query": {"type": "string", "description": "Title or keyword to identify the note or task"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def get_note_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
return {
"success": True,
"type": "note_content",
"data": {"id": note.id, "title": note.title, "body": note.body or "", "tags": note.tags or []},
}
@tool(
name="list_notes",
description="Browse recent notes (not tasks) with optional filters. Returns titles and short previews. Use when the user asks 'show my notes' or wants to browse by tag, project, or recency. For tasks use list_tasks; for topic search use search_notes.",
parameters={
"q": {"type": "string", "description": "Optional keyword filter"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Filter notes that have ALL of these tags"},
"project": {"type": "string", "description": "Filter notes by project name"},
"sort": {"type": "string", "enum": ["updated_at", "created_at", "title"], "description": "Sort field (default: updated_at)"},
"limit": {"type": "integer", "description": "Maximum number of notes to return (default 10)"},
},
read_only=True,
briefing=True,
)
async def list_notes_tool(*, user_id, arguments, **_ctx):
tags_raw = arguments.get("tags")
tags = tags_raw if isinstance(tags_raw, list) else None
project_id = None
project_name = arguments.get("project")
if project_name:
proj = await resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found."}
project_id = proj.id
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or arguments.get("query"),
tags=tags,
is_task=False,
project_id=project_id,
sort=arguments.get("sort", "updated_at"),
order="desc",
limit=int(arguments.get("limit", 10)),
)
results = [
{
"id": n.id,
"title": n.title,
"tags": n.tags or [],
"updated_at": n.updated_at.isoformat(),
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
}
for n in notes
]
return {
"success": True,
"type": "notes_list",
"data": {"total": total, "count": len(results), "results": results},
}
@tool(
name="delete_note",
description="Delete any item from the user's knowledge base permanently — notes, tasks, persons (created via save_person), places (created via save_place), and lists (created via create_list) are all stored as notes and use this single delete tool. Use ONLY when the user explicitly asks to delete or remove an item. Always confirm with the user first — this cannot be undone.",
parameters={
"query": {"type": "string", "description": "Title or keyword to find the item to delete (works for notes, tasks, persons, places, and lists)"},
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this item deleted."},
},
required=["query"],
)
async def delete_note_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=3)
if not notes:
return {"success": False, "error": f"No note or task found matching '{query}'."}
note = notes[0]
if not arguments.get("confirmed"):
item_type = "task" if note.status is not None else "note"
return {
"success": False,
"requires_confirmation": True,
"error": f"Deleting {item_type} '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.",
}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete."}
item_type = "task" if note.status is not None else "note"
return {"success": True, "type": f"{item_type}_deleted", "data": {"id": note.id, "title": note.title}}
@@ -0,0 +1,77 @@
"""User profile tools."""
from __future__ import annotations
from fabledassistant.services.tools._registry import tool
@tool(
name="get_profile",
description=(
"Retrieve the user's stored profile: name, job title, industry, expertise level, "
"preferred response style, tone, and interests. Use this when you need to personalise "
"a response and the user's profile hasn't already been injected into context."
),
parameters={},
read_only=True,
)
async def get_profile_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.user_profile import get_profile as _get_profile
profile = await _get_profile(user_id)
return {
"success": True,
"type": "profile",
"data": {
"display_name": profile.display_name or "",
"job_title": profile.job_title or "",
"industry": profile.industry or "",
"expertise_level": profile.expertise_level or "intermediate",
"response_style": profile.response_style or "balanced",
"tone": profile.tone or "casual",
"interests": profile.interests or [],
},
}
@tool(
name="update_profile",
description=(
"Update the user's stored profile when they share personal information: their name, "
"job, industry, expertise, preferred response style, tone, or interests. "
"Only set fields the user has explicitly mentioned."
),
parameters={
"display_name": {"type": "string", "description": "User's preferred display name"},
"job_title": {"type": "string", "description": "Job title (e.g. 'Software Engineer')"},
"industry": {"type": "string", "description": "Industry (e.g. 'Healthcare', 'Finance')"},
"expertise_level": {"type": "string", "enum": ["novice", "intermediate", "expert"], "description": "User's general expertise level"},
"response_style": {"type": "string", "enum": ["concise", "balanced", "detailed"], "description": "Preferred response length/depth"},
"tone": {"type": "string", "enum": ["casual", "professional", "technical"], "description": "Preferred communication tone"},
"interests": {"type": "array", "items": {"type": "string"}, "description": "List of topics or hobbies the user is interested in"},
},
)
async def update_profile_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.user_profile import VALID_EXPERTISE, VALID_STYLES, VALID_TONES, update_profile as _update_profile
data: dict = {}
for field in ("display_name", "job_title", "industry"):
val = arguments.get(field)
if val is not None:
data[field] = str(val)
expertise = arguments.get("expertise_level")
if expertise in VALID_EXPERTISE:
data["expertise_level"] = expertise
style = arguments.get("response_style")
if style in VALID_STYLES:
data["response_style"] = style
tone = arguments.get("tone")
if tone in VALID_TONES:
data["tone"] = tone
interests = arguments.get("interests")
if isinstance(interests, list):
data["interests"] = [str(i) for i in interests if str(i).strip()]
if not data:
return {"success": False, "error": "No valid fields provided to update."}
await _update_profile(user_id, data)
return {"success": True, "type": "profile_updated", "data": {"fields_updated": list(data.keys())}}
@@ -0,0 +1,258 @@
"""Project and milestone tools."""
from __future__ import annotations
from fabledassistant.services.tools._helpers import fuzzy_title_match, resolve_project
from fabledassistant.services.tools._registry import tool
@tool(
name="create_project",
description="Create a new project. Use list_projects first to check for duplicates. Only call after user has explicitly confirmed.",
parameters={
"title": {"type": "string", "description": "Project title"},
"description": {"type": "string", "description": "Brief description"},
"goal": {"type": "string", "description": "The goal or desired outcome"},
"color": {"type": "string", "description": "Optional hex color for the project (e.g. '#6366f1')"},
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this project created."},
},
required=["title", "confirmed"],
)
async def create_project_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.projects import create_project as _create_project, get_project_by_title as _gpbt, list_projects as _lp
proj_title = arguments.get("title", "New Project")
existing_proj = await _gpbt(user_id, proj_title)
if existing_proj is not None:
return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use that project instead of creating a duplicate."}
all_projects = await _lp(user_id)
near_proj, ratio = fuzzy_title_match(proj_title, all_projects, threshold=0.55)
if near_proj is not None:
return {"success": False, "requires_confirmation": True, "error": f"A project with a similar name '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). This is likely the project you meant — use that project's name instead. Only call create_project again if you are certain this should be a completely new, separate project with a different confirmed=true."}
if not arguments.get("confirmed"):
return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."}
project = await _create_project(
user_id,
title=proj_title,
description=arguments.get("description", ""),
goal=arguments.get("goal", ""),
color=arguments.get("color") or None,
)
return {"success": True, "type": "project", "data": project.to_dict()}
@tool(
name="list_projects",
description="List the user's projects. Use when asked about projects, initiatives, or campaigns.",
parameters={
"status": {"type": "string", "enum": ["active", "completed", "archived"], "description": "Filter by status (omit for all)"},
},
read_only=True,
briefing=True,
)
async def list_projects_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.projects import list_projects as _list_projects
projects = await _list_projects(user_id, status=arguments.get("status"))
return {
"success": True,
"type": "projects_list",
"data": {"count": len(projects), "projects": [p.to_dict() for p in projects]},
}
@tool(
name="get_project",
description="Get details and summary of a specific project.",
parameters={
"query": {"type": "string", "description": "Project name or keyword to find it"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def get_project_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.projects import get_project_by_title as _gpbt, get_project_summary as _gps, list_projects as _lp
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
summary = await _gps(user_id, project.id)
data = project.to_dict()
data["summary"] = summary
return {"success": True, "type": "project_detail", "data": data}
@tool(
name="update_project",
description="Update a project's title, status, description, goal, or color.",
parameters={
"query": {"type": "string", "description": "Project name to find"},
"title": {"type": "string", "description": "New project title"},
"status": {"type": "string", "enum": ["active", "completed", "archived"]},
"description": {"type": "string"},
"goal": {"type": "string"},
"color": {"type": "string", "description": "Hex color (e.g. '#6366f1'), or empty string to clear"},
},
required=["query"],
)
async def update_project_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.projects import get_project_by_title as _gpbt, update_project as _up, list_projects as _lp
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
fields = {}
for k in ("title", "status", "description", "goal"):
if k in arguments:
fields[k] = arguments[k]
if "color" in arguments:
fields["color"] = arguments["color"] or None
updated = await _up(user_id, project.id, **fields)
return {"success": True, "type": "project_updated", "data": updated.to_dict()}
@tool(
name="search_projects",
description="Search for projects by name, description, or theme. Use this to find which project a user is referring to and get its ID for set_rag_scope.",
parameters={
"query": {"type": "string", "description": "Search query — project name, topic, or theme"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def search_projects_tool(*, user_id, arguments, **_ctx):
from difflib import SequenceMatcher
from fabledassistant.services.projects import list_projects
query = str(arguments.get("query", "")).lower()
projects = await list_projects(user_id)
scored: list[tuple[float, object]] = []
for p in projects:
combined = f"{p.title} {p.description or ''} {p.auto_summary or ''}".lower()
base_score = SequenceMatcher(None, query, combined).ratio()
query_words = set(query.split())
overlap = sum(1 for w in query_words if w in combined)
score = base_score + overlap * 0.05
scored.append((score, p))
scored.sort(key=lambda x: x[0], reverse=True)
results = []
for score, p in scored[:5]:
results.append({
"id": p.id,
"title": p.title,
"summary_snippet": (p.auto_summary or p.description or "")[:200],
"score": round(score, 3),
})
return {"type": "projects_list", "data": {"projects": results}}
@tool(
name="create_milestone",
description="Create a milestone inside a project. Confirm name and project with user before calling.",
parameters={
"project": {"type": "string", "description": "Project title"},
"title": {"type": "string", "description": "Milestone title"},
"description": {"type": "string", "description": "Optional description"},
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this milestone created."},
},
required=["project", "title", "confirmed"],
)
async def create_milestone_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.milestones import create_milestone as _cm, get_milestone_by_title as _gmbt2, list_milestones as _lms
project_name = arguments.get("project", "")
ms_title = arguments.get("title", "Untitled Milestone")
if not project_name:
return {"success": False, "error": "project is required"}
if not arguments.get("confirmed"):
return {"success": False, "requires_confirmation": True, "error": f"Milestone creation requires explicit user confirmation. Ask the user: 'Shall I create a milestone titled \"{ms_title}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."}
proj = await resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
existing_ms = await _gmbt2(user_id, proj.id, ms_title)
if existing_ms is not None:
return {"success": False, "error": f"A milestone titled '{existing_ms.title}' already exists in '{proj.title}' (id: {existing_ms.id}). Use update_milestone to modify it instead."}
all_ms = await _lms(user_id, proj.id)
near_ms, ratio = fuzzy_title_match(ms_title, all_ms)
if near_ms is not None:
return {"success": False, "error": f"A milestone with a very similar title '{near_ms.title}' already exists in '{proj.title}' (id: {near_ms.id}, similarity: {ratio:.0%}). Use update_milestone to modify it instead."}
ms = await _cm(user_id, proj.id, title=ms_title, description=arguments.get("description"))
return {"success": True, "type": "milestone", "data": ms.to_dict()}
@tool(
name="update_milestone",
description="Update the title, description, or status of an existing milestone.",
parameters={
"project": {"type": "string", "description": "Project title the milestone belongs to"},
"milestone": {"type": "string", "description": "Current milestone title to look up"},
"title": {"type": "string", "description": "New title (omit to keep current)"},
"description": {"type": "string", "description": "New description (omit to keep current)"},
"status": {"type": "string", "enum": ["active", "done"], "description": "New status: 'active' (in progress) or 'done' (completed)"},
},
required=["project", "milestone"],
)
async def update_milestone_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt, update_milestone as _um
project_name = arguments.get("project", "")
milestone_name = arguments.get("milestone", "")
if not project_name or not milestone_name:
return {"success": False, "error": "Both project and milestone are required"}
proj = await resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found"}
ms = await _gmbt(user_id, proj.id, milestone_name)
if ms is None:
return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."}
fields: dict[str, object] = {}
if "title" in arguments:
fields["title"] = arguments["title"]
if "description" in arguments:
fields["description"] = arguments["description"]
if "status" in arguments:
fields["status"] = arguments["status"]
if not fields:
return {"success": False, "error": "No fields to update — provide at least one of: title, description, status"}
updated = await _um(user_id, ms.id, **fields)
return {"success": True, "type": "milestone", "data": updated.to_dict()}
@tool(
name="list_milestones",
description="List milestones for a project with their progress percentages.",
parameters={
"project": {"type": "string", "description": "Project name to look up"},
},
required=["project"],
read_only=True,
briefing=True,
)
async def list_milestones_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.milestones import get_project_milestone_summary
project_name = arguments.get("project", "")
proj = await resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"No project found matching '{project_name}'"}
summary = await get_project_milestone_summary(user_id, proj.id)
return {
"success": True,
"type": "milestones_list",
"data": {"project": proj.title, "count": len(summary), "milestones": summary},
}
+34
View File
@@ -0,0 +1,34 @@
"""RAG scope tool."""
from __future__ import annotations
from fabledassistant.services.tools._registry import tool
@tool(
name="set_rag_scope",
description="Change the RAG scope for this conversation. Use project_id=<int> to scope to a project, project_id=null to scope to orphan notes only, project_id=-1 for all notes.",
parameters={
"project_id": {"type": ["integer", "null"], "description": "Project ID to scope to, null for orphan-only, -1 for all notes"},
},
required=["project_id"],
)
async def set_rag_scope_tool(*, user_id, arguments, conv_id=None, workspace_project_id=None, **_ctx):
if workspace_project_id is not None:
return {"success": False, "error": "Cannot change RAG scope in workspace view"}
if conv_id is None:
return {"success": False, "error": "No conversation context available"}
project_id = arguments.get("project_id")
if project_id is not None and project_id != -1:
from fabledassistant.services.projects import get_project
proj = await get_project(user_id, int(project_id))
if proj is None:
return {"success": False, "error": "Project not found"}
scope_label = proj.title
elif project_id == -1:
scope_label = "All notes"
else:
scope_label = "Orphan notes only"
from fabledassistant.services.chat import update_conversation
await update_conversation(user_id, conv_id, rag_project_id=project_id)
return {"success": True, "type": "rag_scope_set", "scope_label": scope_label}
+99
View File
@@ -0,0 +1,99 @@
"""RSS and article tools."""
from __future__ import annotations
import logging
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
@tool(
name="get_rss_items",
description="Get recent items from the user's RSS feeds (news, blogs, Reddit, podcasts). Returns titles, URLs, and summaries of recent posts.",
parameters={
"limit": {"type": "integer", "description": "Number of items to return (default 15, max 50)"},
"category": {"type": "string", "description": "Filter by feed category (e.g. 'news', 'tech'). Omit for all."},
},
read_only=True,
briefing=True,
)
async def get_rss_items_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.rss import get_recent_items
limit = min(int(arguments.get("limit", 15)), 50)
items = await get_recent_items(user_id, limit=limit)
return {"data": {"items": items, "count": len(items)}}
@tool(
name="add_rss_feed",
description="Add an RSS/Atom feed. Use when user asks to subscribe to or track a feed, blog, subreddit, or podcast.",
parameters={
"url": {"type": "string", "description": "The RSS/Atom feed URL to add."},
"category": {"type": "string", "description": "Optional category label (e.g. 'news', 'tech', 'reddit'). Omit if unsure."},
},
required=["url"],
)
async def add_rss_feed_tool(*, user_id, arguments, **_ctx):
import asyncio as _asyncio
from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.rss_feed import RssFeed
from fabledassistant.services.rss import fetch_and_cache_feed
url = str(arguments.get("url", "")).strip()
if not url:
return {"error": "url is required"}
category = arguments.get("category") or None
async with _async_session() as session:
existing = await session.execute(
_select(RssFeed).where(RssFeed.user_id == user_id, RssFeed.url == url)
)
if existing.scalars().first():
return {"error": "Feed already added", "url": url}
feed = RssFeed(user_id=user_id, url=url, title="", category=category)
session.add(feed)
await session.commit()
await session.refresh(feed)
feed_id = feed.id
_asyncio.create_task(fetch_and_cache_feed(feed_id, url))
return {"data": {"id": feed_id, "url": url, "message": "Feed added and fetching started."}}
@tool(
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={
"url": {"type": "string", "description": "The URL to fetch and read"},
},
required=["url"],
read_only=True,
briefing=True,
)
async def read_article_tool(*, user_id, arguments, **_ctx):
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,
}
+116
View File
@@ -0,0 +1,116 @@
"""Task tools: list, log work."""
from __future__ import annotations
from fabledassistant.services.notes import get_note_by_title, list_notes
from fabledassistant.services.tools._helpers import (
parse_due_date,
resolve_project,
)
from fabledassistant.services.tools._registry import tool
@tool(
name="list_tasks",
description="List tasks with optional filters. For overdue tasks, set due_before to today's date.",
parameters={
"q": {"type": "string", "description": "Optional keyword filter — searches title and body"},
"status": {"type": "array", "items": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"]}, "description": "Filter by one or more statuses. Omit for all."},
"priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "Filter by priority level"},
"due_before": {"type": "string", "description": "Return tasks due before this date (YYYY-MM-DD). Use today's date to find overdue tasks."},
"due_after": {"type": "string", "description": "Return tasks due on or after this date (YYYY-MM-DD)"},
"project": {"type": "string", "description": "Filter tasks by project name"},
"milestone": {"type": "string", "description": "Filter tasks by milestone name within a project"},
"limit": {"type": "integer", "description": "Maximum number of tasks to return (default 10)"},
},
read_only=True,
briefing=True,
)
async def list_tasks_tool(*, user_id, arguments, **_ctx):
project_id = None
milestone_id = None
project_name = arguments.get("project")
milestone_name = arguments.get("milestone")
if project_name:
proj = await resolve_project(user_id, project_name)
if proj:
project_id = proj.id
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title
ms = await get_milestone_by_title(user_id, proj.id, milestone_name)
if ms:
milestone_id = ms.id
elif milestone_name:
from fabledassistant.services.milestones import find_milestone_by_title
ms = await find_milestone_by_title(user_id, milestone_name)
if ms:
milestone_id = ms.id
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or None,
is_task=True,
status=arguments.get("status"),
priority=arguments.get("priority"),
due_before=parse_due_date(arguments.get("due_before")),
due_after=parse_due_date(arguments.get("due_after")),
project_id=project_id,
milestone_id=milestone_id,
exclude_paused_projects=project_id is None,
limit=int(arguments.get("limit", 10)),
sort="due_date",
order="asc",
)
results = []
for n in notes:
results.append({
"id": n.id,
"title": n.title,
"status": n.status,
"priority": n.priority,
"due_date": str(n.due_date) if n.due_date else None,
"project_id": n.project_id,
"preview": (n.body[:120] + "...") if n.body and len(n.body) > 120 else (n.body or ""),
})
return {
"success": True,
"type": "tasks",
"data": {"total": total, "count": len(results), "results": results},
}
@tool(
name="log_work",
description="Add a work log entry to a task to record progress, work done, or time spent. Use this when the user says they worked on, completed, or spent time on a task.",
parameters={
"task": {"type": "string", "description": "Title or keyword identifying the task (required)"},
"content": {"type": "string", "description": "Description of the work done (required)"},
"duration_minutes": {"type": "integer", "description": "Optional time spent in minutes"},
},
required=["task", "content"],
)
async def log_work_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.task_logs import create_log as _create_log
task_query = arguments.get("task", "")
content = arguments.get("content", "").strip()
if not task_query:
return {"success": False, "error": "task is required"}
if not content:
return {"success": False, "error": "content is required"}
duration_minutes = arguments.get("duration_minutes")
if duration_minutes is not None:
try:
duration_minutes = int(duration_minutes)
except (TypeError, ValueError):
duration_minutes = None
note = await get_note_by_title(user_id, task_query)
if note is None or note.status is None:
notes, _ = await list_notes(user_id=user_id, q=task_query, is_task=True, limit=5)
note = notes[0] if notes else None
if note is None:
return {"success": False, "error": f"No task found matching '{task_query}'."}
try:
log = await _create_log(user_id, note.id, content, duration_minutes)
except ValueError as e:
return {"success": False, "error": str(e)}
return {"success": True, "log": log.to_dict(), "task": note.title}
@@ -0,0 +1,34 @@
"""General-purpose utility tools."""
from __future__ import annotations
from fabledassistant.services.tools._registry import tool
@tool(
name="calculate",
description=(
"Evaluate a mathematical expression and return the exact result. Use this for any "
"arithmetic, percentages, unit conversions, or multi-step calculations where precision "
"matters. Supports standard math operators (+, -, *, /, **, %) and all Python math "
"module functions (sqrt, log, sin, cos, floor, ceil, etc.)."
),
parameters={
"expression": {"type": "string", "description": "A valid Python math expression (e.g. '(450 * 1.13) / 12', 'sqrt(144)', 'log(1000, 10)')"},
},
required=["expression"],
)
async def calculate_tool(*, user_id, arguments, **_ctx):
import math as _math
expr = str(arguments.get("expression", "")).strip()
if not expr:
return {"success": False, "error": "expression is required"}
allowed_names = {k: v for k, v in vars(_math).items() if not k.startswith("_")}
allowed_names["abs"] = abs
allowed_names["round"] = round
try:
result = eval(expr, {"__builtins__": {}}, allowed_names) # noqa: S307
except Exception as calc_err:
return {"success": False, "error": f"Could not evaluate expression: {calc_err}"}
return {"success": True, "type": "calculation", "data": {"expression": expr, "result": result}}

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