Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 939b910372 | |||
| 70cea78c2f | |||
| 4e4dbb8783 | |||
| e4e1d1da49 | |||
| af6f81e0a7 | |||
| 734ccc337f | |||
| 87fcaa6a0d | |||
| 782f36ed51 | |||
| 9a851de624 | |||
| 95135a665b | |||
| 46c6a9f174 | |||
| ec3853a78a | |||
| 0becc1439b | |||
| a6fe1c0d7c | |||
| 77339d5c58 | |||
| e95ad90055 | |||
| 1d6905ccc8 | |||
| 403eb49de0 | |||
| 717ac1b5d7 | |||
| 8276e5050a | |||
| 436e339c48 | |||
| c32c75f77e | |||
| ce2d76447c | |||
| 5275be8588 | |||
| 8d07b6c79e | |||
| 02138f5728 | |||
| 61f95fb9ed | |||
| cc74901761 | |||
| 9f6608fc8f | |||
| e63b4634ea | |||
| 593d737e26 | |||
| 06dbc0e27a | |||
| 7e0938fe7d | |||
| 9eba6ac107 | |||
| 66f906a629 | |||
| 5f4759a5e8 | |||
| 25c767ddf2 | |||
| 7e06e58ab6 | |||
| 2fd914916f | |||
| 4168167f24 | |||
| 22a13636e8 | |||
| aebb6baa2c | |||
| 3f3156db07 | |||
| 102c0b74a0 | |||
| 8fb81bc1ed | |||
| c1bc73da8e | |||
| 3b8d40fea3 | |||
| 2f5abc034e | |||
| 7a01733334 | |||
| 7d5611d00b | |||
| e5821ef3f8 | |||
| 43231f44d2 | |||
| ab0b9c3199 | |||
| d12fc5d282 | |||
| 4c3d67994b | |||
| 126bad7d99 | |||
| 28fae75258 | |||
| 058bd76719 | |||
| d56b57ee40 | |||
| 8762552234 | |||
| 6cbf9be052 | |||
| 3687fbeeb9 | |||
| a6543c1dc5 | |||
| 4558dd578a | |||
| 8647f52fbc | |||
| 304affb837 | |||
| 2e3f90384d | |||
| 16b9ed9392 | |||
| f9c6802939 | |||
| f2debd8a5b | |||
| f8f14eea0f | |||
| 64c19932a7 | |||
| 317d8f25d0 | |||
| 87f74b1cd5 | |||
| f7fda0adca | |||
| 0ca39a2e34 | |||
| 16d9af8b96 | |||
| 2ac894b5d1 | |||
| 7e81e50e3e | |||
| d8945536c4 | |||
| f30e90ef8d | |||
| 00f82f8cba | |||
| f953a36b86 | |||
| 4bc7f9eaac | |||
| 670de547ad | |||
| a684d84edf | |||
| 34729cf1cf | |||
| dd16b39218 | |||
| 0bd362f582 | |||
| 0afa2d8771 | |||
| 93af4e6bab |
+65
-32
@@ -1,13 +1,16 @@
|
||||
# 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 any branch: typecheck + lint + test
|
||||
# Push to dev: gates + build :dev + :<sha>
|
||||
# Tag v* (release): gates + build :latest + :<sha> + :<version>
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# REGISTRY_USER — your Forgejo username
|
||||
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
|
||||
@@ -15,7 +18,7 @@ name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, main]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
@@ -28,8 +31,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,17 +51,23 @@ env:
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
runs-on: py3.12-node22
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
# Cache node_modules directly (not the npm download cache).
|
||||
# npm ci still has to extract + link every module even with a
|
||||
# warm download cache, which is where the real time goes. Caching
|
||||
# the output directory lets us skip npm ci entirely on hits.
|
||||
- name: Cache node_modules
|
||||
id: npm-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
path: frontend/node_modules
|
||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.npm-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
@@ -58,52 +77,56 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
runs-on: py3.12-node22
|
||||
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
|
||||
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 —
|
||||
# main branch pushes run the gates 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
|
||||
runs-on: ci-runner
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -122,11 +145,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 +173,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,216 @@
|
||||
# Agentic Briefing — Design Spec
|
||||
|
||||
**Date:** 2026-04-10
|
||||
**Status:** Proposed
|
||||
**Author:** bvandeusen + Claude
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The current briefing pipeline hallucinates calendar events, tasks, and news items that do not exist in the user's actual data. Observed examples from production:
|
||||
|
||||
- A morning briefing asserting "your dentist appointment is still in progress" when no such event existed
|
||||
- An 8am check-in mentioning "a quick meeting at 10:30 AM" with no backing calendar entry
|
||||
- A midday check-in inventing "a team huddle at 2:30 PM" and "the Q2 budget draft due by Friday"
|
||||
- The model calling `search_images` in response to a clarifying question about a fabricated meeting
|
||||
|
||||
These are not bugs in data retrieval — the data gathering code (`_gather_internal`, `_gather_external`) returns correct values. They are a structural consequence of how the briefing context is assembled.
|
||||
|
||||
## Root cause — the "no receipt" problem
|
||||
|
||||
The current `run_compilation` pipeline does this:
|
||||
|
||||
1. Python code gathers data (tasks, events, weather, news) from the database
|
||||
2. Python formats the data into a structured text blob as the user-role message: `TODAY'S EVENTS: ...`, `DUE TODAY: ...`, etc.
|
||||
3. The LLM is called **once** with `[system_prompt, user_prompt]` and produces prose
|
||||
4. Only the prose reply is written to the conversation. **The underlying data is never persisted in the conversation history.**
|
||||
|
||||
When the user later chats in the briefing conversation, the chat endpoint loads the full conversation history — which contains the model's *prose* from earlier but not the data that prose was derived from. The model's own prior output becomes the only source of "truth" available for follow-up questions. If that prose asserted a fact (real or hallucinated), the model has no way to distinguish it from ground truth when generating the next reply, and it will double down.
|
||||
|
||||
Compounding factors:
|
||||
|
||||
- **Empty sections are silently omitted.** If `calendar_events` is empty, the user prompt contains no `TODAY'S EVENTS:` line at all. The model has no explicit "zero events" signal — combined with an imperative system prompt ("note calendar events and tasks"), it interprets the silence as "I should mention some" and fabricates.
|
||||
- **Scheduled slot injections append synthetic turns.** `run_slot_injection` writes a fake `[Midday briefing update]` user message and the assistant reply into the persistent conversation. By evening, the chat history contains three separate briefings, each potentially with errors, all treated as equal-weight context on follow-up.
|
||||
- **The `search_images` tool is available during briefing chat**, with a negative-instruction description ("Not for factual questions"). Small and mid-sized models frequently ignore negative guidance in tool descriptions and call the tool anyway.
|
||||
|
||||
## Solution — agentic briefing (the receipt model)
|
||||
|
||||
Replace the one-shot synthesis with a tool-call loop. The briefing is no longer "a text blob synthesized from pre-gathered data." It is **a scheduled agent run**: the LLM is given a system prompt, a curated set of read-only data tools, and a trigger ("generate the morning briefing"). The model must call tools to see what exists. Every tool call and tool result becomes part of the conversation history, where it lives as a permanent, structured receipt.
|
||||
|
||||
### Why this fixes the hallucination
|
||||
|
||||
When the model calls `list_events(today) → []`, that empty array is now a persistent message in the conversation. On a follow-up question like "what meeting?", the chat endpoint loads the conversation and the model sees its own tool result from the morning showing no events. Answering "you had a meeting at 10:30" would require the model to directly contradict a tool result sitting two messages back — something LLMs are much more reliable at avoiding than contradicting their own prior prose.
|
||||
|
||||
In short: **the model cannot fabricate what has a visible receipt proving it does not exist.**
|
||||
|
||||
### What the model sees — before vs after
|
||||
|
||||
**Before** — the model sees one structured blob and produces prose. The blob is discarded.
|
||||
|
||||
```
|
||||
[system] You are a personal assistant... weave together what matters...
|
||||
[user] Date: 2026-04-10
|
||||
WEATHER: Home — partly cloudy, 8–16°C
|
||||
DUE TODAY: Fix briefing hallucinations
|
||||
OVERDUE (2 tasks): ...
|
||||
[assistant] Morning! Looks like a quiet day with two overdue items...
|
||||
```
|
||||
|
||||
**After** — the model is given tools and iterates to ground truth.
|
||||
|
||||
```
|
||||
[system] You are the user's personal assistant giving their morning briefing.
|
||||
Use tools to see what's relevant. Only mention things you learned from
|
||||
tool calls. If a tool returns nothing, say so honestly.
|
||||
[user] Generate the morning briefing.
|
||||
[assistant] tool_call: list_tasks(filter="due_today")
|
||||
[tool] [{"id": 81, "title": "Fix briefing hallucinations", ...}]
|
||||
[assistant] tool_call: list_tasks(filter="overdue")
|
||||
[tool] [{"id": 42, ...}, {"id": 43, ...}]
|
||||
[assistant] tool_call: list_events(today)
|
||||
[tool] []
|
||||
[assistant] tool_call: get_weather(location="home")
|
||||
[tool] {"description": "partly cloudy", "temp_min": 8, "temp_max": 16}
|
||||
[assistant] tool_call: get_rss_items(max=8)
|
||||
[tool] [...]
|
||||
[assistant] Morning! Partly cloudy today, 8 to 16 — nothing on the calendar
|
||||
so it's a clean run at the desk. Two things to keep in mind...
|
||||
```
|
||||
|
||||
The conversation now contains verifiable receipts: `list_events(today)` returned `[]`, and that result sits in context forever (until pruned). Follow-up questions operate against those receipts.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Existing infrastructure (reused, not rebuilt)
|
||||
|
||||
The codebase already has the agentic primitives — they're used for regular chat:
|
||||
|
||||
- **`llm.py::stream_chat_with_tools`** — the streaming tool-use loop that talks to Ollama with a `tools` parameter and yields tool-call chunks
|
||||
- **`generation_task.py::_stream_with_retry`** — wraps `stream_chat_with_tools` with retry-on-500 behavior for cold-model races
|
||||
- **`tools.py`** — defines 40+ tool schemas and an execution dispatcher
|
||||
|
||||
The briefing bypasses all of this and calls a one-shot `_llm_synthesise` helper. The refactor is mainly "route briefings through the same pipeline regular chat already uses."
|
||||
|
||||
### New modules
|
||||
|
||||
**`briefing_tools.py`** — a small wrapper exposing a curated read-only subset of `tools.py` for briefing runs. This is an **explicit allowlist**, not a blocklist, so newly-added tools must be opted in:
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `list_tasks` (with filter args) | See what's actionable today, overdue, high priority |
|
||||
| `list_events` (today / upcoming) | Know what's on the calendar |
|
||||
| `get_weather` | Current/forecast weather |
|
||||
| `get_rss_items` | Pull news themes filtered by user preferences |
|
||||
| `list_projects` | Understand project context |
|
||||
| `search_projects` | Surface active project summaries |
|
||||
| `list_notes` (recent) | Capture follow-ups from yesterday |
|
||||
|
||||
**Explicitly omitted** from the briefing tool set:
|
||||
|
||||
- `search_images`, `search_web`, `research_topic`, `read_article` — external search is not a briefing concern, and `search_images` is the source of the "Peter Kyle Science Secretary" image-search bug
|
||||
- All `create_*`, `update_*`, `delete_*` — briefings are read-only; a scheduled background job must not decide to mutate the user's data on its own
|
||||
- `set_rag_scope`, `calculate` — not relevant to briefing content
|
||||
|
||||
### New briefing function
|
||||
|
||||
**`briefing_pipeline.py::run_agentic_briefing(user_id, slot, model, conversation_id)`** replaces `run_compilation`'s body (and eventually `run_slot_injection`). Internally:
|
||||
|
||||
1. Build a slot-specific system prompt (see below)
|
||||
2. Load the curated briefing tools from `briefing_tools.py`
|
||||
3. Seed messages with `[system, user]` where the user message is a simple trigger like `"Generate the morning briefing."`
|
||||
4. Enter a tool-call loop (max 8 iterations):
|
||||
- Call `stream_chat_with_tools`
|
||||
- If the model returns `tool_calls`, execute them via the existing dispatcher, append tool results, continue
|
||||
- If the model returns a final assistant message with no pending tool calls, break
|
||||
5. Return `(final_prose, full_message_list, metadata)`
|
||||
|
||||
The full message list is important: it's written to the conversation along with the final prose, so the tool-call receipts become part of the persistent record.
|
||||
|
||||
### Slot-specific system prompts
|
||||
|
||||
Compilation (full morning briefing):
|
||||
|
||||
```
|
||||
You are the user's personal assistant giving their full morning briefing.
|
||||
Use the tools available to see what's actually relevant today — tasks due,
|
||||
overdue items, events on the calendar, weather, news themes, project state
|
||||
— and weave it into a warm, natural-sounding summary.
|
||||
|
||||
Rules:
|
||||
- Call tools to see the data. Never assert facts you didn't learn from a tool.
|
||||
- If a tool returns nothing (no events today, no overdue tasks), say so
|
||||
honestly. Don't fabricate items to fill space.
|
||||
- Write flowing prose. No markdown, no headers, no bullet points.
|
||||
- Aim for 6–10 sentences. Skip topics that have nothing interesting.
|
||||
- Close on one or two concrete, actionable suggestions.
|
||||
|
||||
User profile (for tone and preferences):
|
||||
{profile_body}
|
||||
```
|
||||
|
||||
Check-ins (midday, afternoon):
|
||||
|
||||
```
|
||||
You are the user's personal assistant giving a brief {slot} check-in.
|
||||
Use tools to see what's changed since this morning. Focus on progress
|
||||
and what's still unaddressed.
|
||||
|
||||
Rules:
|
||||
- Call tools to see current state. Never assert facts without tool results.
|
||||
- If nothing meaningful has changed, say so briefly — don't invent progress.
|
||||
- 3–5 sentences, natural prose, no markdown.
|
||||
```
|
||||
|
||||
### Conversation hygiene — removing the fake user messages
|
||||
|
||||
The current scheduler appends two fake messages on every slot injection:
|
||||
|
||||
```python
|
||||
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
|
||||
await post_message(conv.id, "assistant", text)
|
||||
```
|
||||
|
||||
Under the agentic model, we drop the fake `"user"` message entirely. Slot updates become plain assistant messages tagged with `metadata.briefing_slot = "midday"`. The chat endpoint's message loader is updated to filter these when building the LLM context on follow-ups, so a user chatting in the briefing conversation doesn't see three earlier briefings smashed into their history. The UI continues to show them as visible timeline entries.
|
||||
|
||||
(This is the Option A filter from the earlier discussion — a small, surgical change compared to migrating briefings to a separate sidecar table. Sidecar storage remains a possible future step.)
|
||||
|
||||
### Ollama setup compatibility
|
||||
|
||||
The existing Ollama deployment uses non-parallel mode across two GPUs, with a concern about context duplication between cards. This is the correct setup for agentic briefings:
|
||||
|
||||
- Each tool-call iteration shares the same KV cache on the same GPU, so appending tool results is cheap
|
||||
- There is no race where a second iteration could land on a different card with a different cache state
|
||||
- The trade-off is serialization: a briefing in progress will block a concurrent user chat request until it finishes, but scheduled briefings are rare enough (4× per day) that this is acceptable
|
||||
|
||||
If GPU contention becomes a problem later, the right lever is pinning specific models to specific GPUs (e.g., background tasks on GPU 1, interactive models on GPU 0) — not enabling parallelism.
|
||||
|
||||
## Cost & trade-offs
|
||||
|
||||
- **Latency:** a briefing now makes 5–7 inference calls (one per tool-call decision plus the final prose) instead of 1. On a local Ollama with a 7B model, expect 15–40s per briefing vs the current 5–10s. Acceptable for a background job; if it becomes painful at the user-facing check-in slots, investigate letting the model batch independent tool calls in a single turn (Ollama supports this).
|
||||
- **Model requirements:** the default model must be reliable at tool calling. `qwen2.5:7b`, `llama3.1:8b`, `mistral-small-3:24b`, and similar handle it well. Models in the ≤3B class typically fail — they either emit no tool calls and return empty prose, or hallucinate invalid tool arguments. If a user's `default_model` is too small, agentic mode should fall back to legacy mode with a log warning.
|
||||
- **Context growth:** tool results bloat the conversation. At 8 tool calls per compilation × 4 slots per day, a daily briefing conversation can reach 20-30 KB of JSON-heavy history. Fine for a day; aged briefings should be archived/rolled up after 7 days.
|
||||
- **Tool subset drift:** someone adds a new mutating tool to `tools.py` and forgets to update the briefing allowlist. Mitigated by the allowlist model — the default for new tools is "not exposed to briefings."
|
||||
- **Infinite loop safety:** a buggy model could tool-call forever. Hard cap at 8 iterations, log a warning if hit, return whatever prose was last produced (or a fallback message).
|
||||
|
||||
## Migration path
|
||||
|
||||
Ship incrementally, each step independently reversible:
|
||||
|
||||
**PR 1 — Agentic compilation behind a feature flag.** Add `briefing_tools.py`, add `run_agentic_briefing`, add a per-user setting `briefing_mode: "legacy" | "agentic"` (default legacy). Route only the 4am compilation through the new path when the flag is set. Keep slot-injection on the legacy path. Enable the flag on the author's account first, validate output quality over several days, then flip the default for all users. No DB migration required — the setting lives in the existing `settings` table.
|
||||
|
||||
**PR 2 — Agentic slot injections + conversation hygiene.** Migrate midday/afternoon check-ins to the same pipeline. Remove the fake `[Midday briefing update]` user-role message; slot updates become plain assistant messages tagged with `metadata.briefing_slot`. Add a chat-message-loader filter that excludes slot-tagged messages from the LLM context on follow-ups (they remain visible in the UI).
|
||||
|
||||
**PR 3 — UI polish.** Collapsed tool-call status row in the briefing card ("✓ checked calendar · ✓ looked at tasks · ✓ pulled weather"), expanding to show tool results on click. Tool-result cards (weather, news, task list) rendered inline where useful.
|
||||
|
||||
**PR 4 (optional) — Sidecar storage for briefing snapshots.** If the chat-filter approach in PR 2 feels too hacky, migrate briefings out of the conversations table and into a dedicated `briefing_snapshots` table. Frontend renders them as pinned timeline cards separate from chat. Larger refactor; defer until PR 1–3 prove the approach works.
|
||||
|
||||
## Secondary win — tightening the main chat system prompt
|
||||
|
||||
The regular chat is already agentic (it uses `stream_chat_with_tools`), but its system prompt does not explicitly require the model to ground factual claims in tool results. The prompt discipline introduced for briefings — *"Never assert facts you didn't learn from a tool. If a tool returns nothing, say so honestly."* — is worth applying to the main chat system prompt in a follow-up PR. The mechanism already works; only the framing needs tightening.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The Android Flutter client's failure to render `search_images` tool-result cards. This is a separate rendering gap in the mobile app and does not affect the server-side fix. Tracked separately.
|
||||
- Re-evaluating the Ollama parallelism setting. The current non-parallel config is correct for this work.
|
||||
- Replacing the background model for title/summary/observation extraction. That model's role is unrelated to briefings.
|
||||
+17
-18
@@ -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 400–800ms on conversational replies.
|
||||
|
||||
**Prior-work fast-path** — `_PRIOR_WORK_REFS` regex detects phrases like "research you did", "note you made", "using your research" and returns no-tool immediately, preventing `search_web` from firing when the user references existing notes.
|
||||
|
||||
If a tool is detected, the intent's one-sentence `ack` field is streamed as the first chunk (TTFT), the tool executes, then the main model generates a follow-up with the tool result. For chat-only responses the main model streams directly.
|
||||
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. A thinking-mode heuristic (`_should_think()`) detects complex prompts and enables extended reasoning.
|
||||
|
||||
### 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 (2–8 sections with title + focus)
|
||||
5. Each section synthesised in parallel from relevant sources
|
||||
6. Executive summary generated from all section content
|
||||
7. Index note created with executive summary + links to section notes; section notes linked back via `parent_id`
|
||||
8. Falls back to single-note synthesis if outline generation fails
|
||||
|
||||
SearXNG tip: add the app server IP to `botdetection.ip_lists.pass_ip` in SearXNG `settings.yml` to bypass the rate limiter for trusted backend requests.
|
||||
|
||||
|
||||
+1
-1
@@ -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. Mon–Fri 9am–5pm" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Website</label>
|
||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Category</label>
|
||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove place fields from sidebar**
|
||||
|
||||
Find and delete:
|
||||
|
||||
```html
|
||||
<!-- Place metadata -->
|
||||
<template v-if="noteType === 'place'">
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Address</label>
|
||||
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Phone</label>
|
||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Hours</label>
|
||||
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9–5" @input="markDirty" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add list item types and state**
|
||||
|
||||
In the `<script setup>`, after the `notesExpanded` ref, add:
|
||||
|
||||
```ts
|
||||
// ── List builder ─────────────────────────────────────────────────────────────
|
||||
interface ListItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
const listItems = ref<ListItem[]>([]);
|
||||
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
|
||||
const lines = bodyText.split('\n');
|
||||
const items: ListItem[] = [];
|
||||
const extraLines: string[] = [];
|
||||
let pastList = false;
|
||||
for (const line of lines) {
|
||||
const stripped = line.trimStart();
|
||||
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
|
||||
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
|
||||
} else if (!pastList && stripped === '' && items.length > 0) {
|
||||
pastList = true;
|
||||
} else {
|
||||
pastList = true;
|
||||
extraLines.push(line);
|
||||
}
|
||||
}
|
||||
return { items, extra: extraLines.join('\n').trim() };
|
||||
}
|
||||
|
||||
function serializeListToBody(): string {
|
||||
const listPart = listItems.value
|
||||
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
||||
.join('\n');
|
||||
const extraPart = body.value.trim();
|
||||
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
|
||||
}
|
||||
|
||||
function addListItem(afterIndex?: number) {
|
||||
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
|
||||
listItems.value.splice(idx, 0, { text: '', checked: false });
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
listItemRefs.value[idx]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function removeListItem(index: number) {
|
||||
if (listItems.value.length <= 1) return;
|
||||
listItems.value.splice(index, 1);
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
const focusIdx = Math.max(0, index - 1);
|
||||
listItemRefs.value[focusIdx]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function onListItemKeydown(e: KeyboardEvent, index: number) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addListItem(index);
|
||||
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
|
||||
e.preventDefault();
|
||||
removeListItem(index);
|
||||
}
|
||||
}
|
||||
|
||||
function onListItemInput(index: number) {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function toggleListItemCheck(index: number) {
|
||||
listItems.value[index].checked = !listItems.value[index].checked;
|
||||
markDirty();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Initialize list items on mount**
|
||||
|
||||
In the onMounted data-loading section, after `notesExpanded.value = !!(store.currentNote.body || '').trim();`, add:
|
||||
|
||||
```ts
|
||||
if (noteType.value === 'list') {
|
||||
const parsed = parseListFromBody(body.value);
|
||||
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
|
||||
body.value = parsed.extra;
|
||||
notesExpanded.value = !!parsed.extra;
|
||||
}
|
||||
```
|
||||
|
||||
And in the new-note branch (the `else` block after loading), after `noteType.value = qt as NoteType;`, add:
|
||||
|
||||
```ts
|
||||
if (noteType.value === 'list') {
|
||||
listItems.value = [{ text: '', checked: false }];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update save to serialize list**
|
||||
|
||||
In the `save` function, find where the body is prepared for the API call. Before the `apiPost` or `apiPatch` call that sends the note data, add list serialization. Find the save function's data construction. Add before the API call:
|
||||
|
||||
```ts
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
```
|
||||
|
||||
Then use `finalBody` instead of `body.value` in the API payload. Find all occurrences of `body: body.value` in the save function and replace with `body: finalBody`.
|
||||
|
||||
- [ ] **Step 6: Add list builder template**
|
||||
|
||||
In the `note-main` div, after the place `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
|
||||
|
||||
```html
|
||||
<!-- ── List builder ─────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'list'">
|
||||
<div class="list-builder">
|
||||
<div
|
||||
v-for="(item, idx) in listItems"
|
||||
:key="idx"
|
||||
class="lb-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.checked"
|
||||
@change="toggleListItemCheck(idx)"
|
||||
class="lb-check"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
||||
v-model="item.text"
|
||||
class="lb-text"
|
||||
placeholder="List item..."
|
||||
@keydown="onListItemKeydown($event, idx)"
|
||||
@input="onListItemInput(idx)"
|
||||
/>
|
||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
||||
</div>
|
||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add list builder CSS**
|
||||
|
||||
Add to the `<style scoped>` block:
|
||||
|
||||
```css
|
||||
/* ── List builder ───────────────────────────────────────── */
|
||||
.list-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.lb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.lb-check {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.lb-text {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.lb-text:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.lb-text::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.lb-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, color 0.12s;
|
||||
}
|
||||
.lb-item:hover .lb-delete,
|
||||
.lb-text:focus ~ .lb-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
.lb-delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.lb-add {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.lb-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Handle the generic note template wrapper**
|
||||
|
||||
Make sure the existing TipTap-first editor content is wrapped in `<template v-else>` (not `v-else-if="noteType === 'note'"`) so it serves as the default for any unrecognized type.
|
||||
|
||||
The final structure in `.note-main` should be:
|
||||
|
||||
```
|
||||
<template v-if="noteType === 'person'"> ... </template>
|
||||
<template v-else-if="noteType === 'place'"> ... </template>
|
||||
<template v-else-if="noteType === 'list'"> ... </template>
|
||||
<template v-else> ... existing TipTap editor ... </template>
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/NoteEditorView.vue
|
||||
git commit -m "feat(editor): place form-first layout and list builder with Enter-to-add"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Backend — new person/place fields in knowledge cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/knowledge.py`
|
||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
||||
|
||||
**Context:** The knowledge card display should show the new fields (birthday, organization for person; website, category for place). The backend `_note_to_item` needs to include them. The frontend card rendering needs to display the useful ones.
|
||||
|
||||
- [ ] **Step 1: Update `_note_to_item` for person**
|
||||
|
||||
In `src/fabledassistant/services/knowledge.py`, find:
|
||||
|
||||
```python
|
||||
if note.entity_type == "person":
|
||||
item["relationship"] = meta.get("relationship", "")
|
||||
item["email"] = meta.get("email", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
if note.entity_type == "person":
|
||||
item["relationship"] = meta.get("relationship", "")
|
||||
item["email"] = meta.get("email", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
item["birthday"] = meta.get("birthday", "")
|
||||
item["organization"] = meta.get("organization", "")
|
||||
item["address"] = meta.get("address", "")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `_note_to_item` for place**
|
||||
|
||||
Find:
|
||||
|
||||
```python
|
||||
elif note.entity_type == "place":
|
||||
item["address"] = meta.get("address", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
item["hours"] = meta.get("hours", "")
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
elif note.entity_type == "place":
|
||||
item["address"] = meta.get("address", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
item["hours"] = meta.get("hours", "")
|
||||
item["website"] = meta.get("website", "")
|
||||
item["category"] = meta.get("category", "")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update KnowledgeItem interface**
|
||||
|
||||
In `frontend/src/views/KnowledgeView.vue`, find the `KnowledgeItem` interface and add the new fields:
|
||||
|
||||
After `phone?: string;` add:
|
||||
```ts
|
||||
birthday?: string;
|
||||
organization?: string;
|
||||
```
|
||||
|
||||
After `hours?: string;` add:
|
||||
```ts
|
||||
website?: string;
|
||||
category?: string;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update person card display**
|
||||
|
||||
In the template, find the person card specifics:
|
||||
|
||||
```html
|
||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
||||
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
|
||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update place card display**
|
||||
|
||||
Find:
|
||||
|
||||
```html
|
||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
||||
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
|
||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles and backend syntax**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
python -c "import ast; ast.parse(open('src/fabledassistant/services/knowledge.py').read()); print('OK')"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/knowledge.py frontend/src/views/KnowledgeView.vue
|
||||
git commit -m "feat(knowledge): show organization/birthday for person cards, category for place cards"
|
||||
```
|
||||
@@ -0,0 +1,785 @@
|
||||
# Modern Fable Visual Identity — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the generic indigo dark-mode palette with a distinctive "Modern Fable" visual identity — deep violet + muted gold, signature card types, pill nav, Fraunces-as-narrator typography, and living micro-details.
|
||||
|
||||
**Architecture:** Pure frontend changes across theme CSS, AppHeader, AppLogo, KnowledgeView, ChatPanel, BriefingView, and CalendarView. No backend changes. Each task is independently deployable — palette first, then cards, then nav, then typography, then details.
|
||||
|
||||
**Tech Stack:** Vue 3 SFC (scoped CSS), CSS custom properties, Fraunces font (already loaded).
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Modify | `frontend/src/assets/theme.css` |
|
||||
| Modify | `frontend/src/components/AppLogo.vue` |
|
||||
| Modify | `frontend/src/components/AppHeader.vue` |
|
||||
| Modify | `frontend/src/views/KnowledgeView.vue` |
|
||||
| Modify | `frontend/src/components/ChatPanel.vue` |
|
||||
| Modify | `frontend/src/views/BriefingView.vue` |
|
||||
| Modify | `frontend/src/views/CalendarView.vue` |
|
||||
| Modify | `frontend/src/App.vue` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Color palette update + logo + scrollbar
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/assets/theme.css`
|
||||
- Modify: `frontend/src/components/AppLogo.vue`
|
||||
|
||||
- [ ] **Step 1: Update the dark theme palette in theme.css**
|
||||
|
||||
In `frontend/src/assets/theme.css`, find the `[data-theme="dark"]` block and replace these values:
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #0f0f14;
|
||||
--color-bg-secondary: #16161f;
|
||||
--color-bg-card: #1a1a24;
|
||||
--color-surface: #16161f;
|
||||
--color-text: #e4e4f0;
|
||||
--color-text-secondary: #8888a8;
|
||||
--color-text-muted: #52526a;
|
||||
--color-border: rgba(124, 58, 237, 0.12);
|
||||
--color-input-border: rgba(124, 58, 237, 0.22);
|
||||
--color-primary: #a78bfa;
|
||||
--color-danger: #f44336;
|
||||
--color-tag-bg: #2a2a45;
|
||||
--color-tag-text: #c4b5fd;
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
--color-toast-success: #4caf50;
|
||||
--color-toast-error: #f44336;
|
||||
--color-status-todo: #9aa0a6;
|
||||
--color-status-todo-bg: #2a2a35;
|
||||
--color-status-in-progress: #a78bfa;
|
||||
--color-status-in-progress-bg: #2a2a45;
|
||||
--color-status-done: #4caf50;
|
||||
--color-status-done-bg: #1b3a20;
|
||||
--color-priority-low: #80cbc4;
|
||||
--color-priority-low-bg: #1a3a38;
|
||||
--color-priority-medium: #fdd835;
|
||||
--color-priority-medium-bg: #3a3520;
|
||||
--color-priority-high: #f44336;
|
||||
--color-priority-high-bg: #3a1a1a;
|
||||
--color-wikilink: #c4b5fd;
|
||||
--color-wikilink-bg: #2a1a45;
|
||||
--color-overdue: #f44336;
|
||||
--color-code-bg: #12121a;
|
||||
--color-code-inline-bg: #1a1a2a;
|
||||
--color-table-stripe: #14141e;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #facc15;
|
||||
--color-input-bar-bg: #1a1a24;
|
||||
--color-input-bar-text: #e4e4f0;
|
||||
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
|
||||
--color-overlay: rgba(0, 0, 0, 0.65);
|
||||
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
|
||||
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
|
||||
--color-bubble-user-text: #b0b0c8;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-accent-warm: #d4a017;
|
||||
--color-accent-warm-light: #e8c45a;
|
||||
--color-primary-solid: #7c3aed;
|
||||
--color-primary-deep: #5b21b6;
|
||||
}
|
||||
```
|
||||
|
||||
Note: `--color-accent-warm`, `--color-accent-warm-light`, `--color-primary-solid`, and `--color-primary-deep` are new variables.
|
||||
|
||||
- [ ] **Step 2: Update the light theme palette**
|
||||
|
||||
In the `:root` block, update these values:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--color-bg: #f5f5fb;
|
||||
--color-bg-secondary: #ededf5;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-surface: #f0f0f8;
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-secondary: #666666;
|
||||
--color-text-muted: #999999;
|
||||
--color-border: #dddde8;
|
||||
--color-input-border: #c8c8d8;
|
||||
--color-primary: #7c3aed;
|
||||
--color-danger: #d93025;
|
||||
--color-tag-bg: #ede5ff;
|
||||
--color-tag-text: #6d28d9;
|
||||
--color-shadow: rgba(0, 0, 0, 0.08);
|
||||
--color-toast-success: #34a853;
|
||||
--color-toast-error: #d93025;
|
||||
--color-status-todo: #5f6368;
|
||||
--color-status-todo-bg: #e8eaed;
|
||||
--color-status-in-progress: #7c3aed;
|
||||
--color-status-in-progress-bg: #ede5ff;
|
||||
--color-status-done: #34a853;
|
||||
--color-status-done-bg: #e6f4ea;
|
||||
--color-priority-low: #5f9ea0;
|
||||
--color-priority-low-bg: #e0f2f1;
|
||||
--color-priority-medium: #f9a825;
|
||||
--color-priority-medium-bg: #fff8e1;
|
||||
--color-priority-high: #d93025;
|
||||
--color-priority-high-bg: #fce8e6;
|
||||
--color-wikilink: #7b1fa2;
|
||||
--color-wikilink-bg: #f3e5f5;
|
||||
--color-overdue: #d93025;
|
||||
--color-code-bg: #f0f0f8;
|
||||
--color-code-inline-bg: #eaeaf4;
|
||||
--color-table-stripe: #f4f4fb;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #eab308;
|
||||
--color-input-bar-bg: #eaeaf3;
|
||||
--color-input-bar-text: #1a1a1a;
|
||||
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
|
||||
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
|
||||
--color-bubble-user-text: #3a3a4a;
|
||||
--color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
/* New brand variables */
|
||||
--color-accent-warm: #b8860b;
|
||||
--color-accent-warm-light: #d4a017;
|
||||
--color-primary-solid: #7c3aed;
|
||||
--color-primary-deep: #5b21b6;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update scrollbar color**
|
||||
|
||||
Find:
|
||||
```css
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(99, 102, 241, 0.45);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(124, 58, 237, 0.45);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update focus ring**
|
||||
|
||||
Find:
|
||||
```css
|
||||
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update AppLogo gradient**
|
||||
|
||||
In `frontend/src/components/AppLogo.vue`, the logo uses `var(--color-primary)` which will automatically pick up the new violet value. No code change needed — the CSS variable update handles it.
|
||||
|
||||
However, add a gradient `<defs>` for the book fill to use the deep gradient instead of a flat color. Find the `<style scoped>` block:
|
||||
|
||||
```css
|
||||
.logo-book {
|
||||
fill: var(--color-primary);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.logo-book {
|
||||
fill: url(#logo-gradient);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
```
|
||||
|
||||
And add a gradient definition inside the `<svg>` element, before the `<!-- Book body -->` comment:
|
||||
|
||||
```html
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="var(--color-primary-solid)" />
|
||||
<stop offset="100%" stop-color="var(--color-primary-deep)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/assets/theme.css frontend/src/components/AppLogo.vue
|
||||
git commit -m "feat(theme): shift palette from indigo to deep violet + muted gold"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Signature header — pill nav + brand shortening + status pulse
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/AppHeader.vue`
|
||||
|
||||
- [ ] **Step 1: Update brand text in header**
|
||||
|
||||
Find:
|
||||
```html
|
||||
Fabled Assistant
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```html
|
||||
<span class="brand-text">Fabled</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wrap nav-center links in a pill container**
|
||||
|
||||
Find the `nav-center` div:
|
||||
```html
|
||||
<div class="nav-center">
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```html
|
||||
<div class="nav-center">
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace header and nav CSS**
|
||||
|
||||
Replace the entire `<style scoped>` from `.app-header` through `.nav-link.router-link-active` with:
|
||||
|
||||
```css
|
||||
.app-header {
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.08);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
padding: 0.6rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Left — brand */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-text {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: #c4b0f0;
|
||||
}
|
||||
|
||||
/* Center — pill bar */
|
||||
.nav-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.nav-pill-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(124, 58, 237, 0.06);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Right */
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: #c4b5fd;
|
||||
font-weight: 600;
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
box-shadow: 0 0 12px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add status dot pulse animation for loaded state**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
```
|
||||
|
||||
Find:
|
||||
```css
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
```
|
||||
|
||||
Add after it:
|
||||
```css
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
|
||||
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update mobile menu active styling**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.mobile-menu .nav-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.mobile-menu .nav-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mobile-menu .nav-link.router-link-active {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
box-shadow: none;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/AppHeader.vue
|
||||
git commit -m "feat(header): pill nav bar, brand shortening, status pulse, header gradient"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Card type DNA — gradient bars, corner accents, hover bloom
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
||||
|
||||
**Context:** The cards currently have a left accent strip per type. The new design replaces this with top gradient bars (notes, tasks, lists) and corner accents (person, place), plus a unified violet hover bloom.
|
||||
|
||||
- [ ] **Step 1: Replace card accent strips with type-specific top bars and borders**
|
||||
|
||||
Find in the `<style scoped>`:
|
||||
```css
|
||||
/* Type accent strip */
|
||||
.k-card--person { border-left: 3px solid #10b981; }
|
||||
.k-card--place { border-left: 3px solid #f59e0b; }
|
||||
.k-card--list { border-left: 3px solid #38bdf8; }
|
||||
.k-card--note { border-left: 3px solid #6366f1; }
|
||||
.k-card--task { border-left: 3px solid #a78bfa; }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
/* Type-specific card DNA */
|
||||
.k-card--note { border-color: rgba(124, 58, 237, 0.12); }
|
||||
.k-card--task { border-color: rgba(212, 160, 23, 0.10); }
|
||||
.k-card--person { border-color: rgba(16, 185, 129, 0.10); }
|
||||
.k-card--place { border-color: rgba(245, 158, 11, 0.10); }
|
||||
.k-card--list { border-color: rgba(56, 189, 248, 0.10); }
|
||||
|
||||
/* Top gradient bars */
|
||||
.k-card--note::before,
|
||||
.k-card--task::before,
|
||||
.k-card--list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
}
|
||||
.k-card--note::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #7c3aed, #a78bfa);
|
||||
}
|
||||
.k-card--task::before {
|
||||
width: 50%;
|
||||
background: linear-gradient(90deg, #d4a017, transparent);
|
||||
}
|
||||
.k-card--list::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
|
||||
}
|
||||
|
||||
/* Corner accents for entity types */
|
||||
.k-card--person::after,
|
||||
.k-card--place::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 0 14px 0 60px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
|
||||
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update card hover to violet bloom**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.k-card:hover {
|
||||
border-color: rgba(255,255,255,0.14);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.k-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add sidebar section dividers**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.filter-section { margin-bottom: 20px; }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.filter-section { margin-bottom: 20px; }
|
||||
.filter-section + .filter-section::before {
|
||||
content: '· · ·';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: rgba(124, 58, 237, 0.3);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.4em;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add scroll fade to card grid**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.card-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.card-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update task card dates to use amber**
|
||||
|
||||
Find in the task card CSS:
|
||||
```css
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-accent-warm);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add Fraunces view title and update sidebar labels**
|
||||
|
||||
Find the filter panel label CSS:
|
||||
```css
|
||||
.filter-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.filter-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update empty state with Fraunces narrator voice**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
|
||||
<p>Nothing here yet.</p>
|
||||
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">Try clearing the filters.</p>
|
||||
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```html
|
||||
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
|
||||
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
|
||||
<p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Add CSS:
|
||||
```css
|
||||
.empty-narrator {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
color: var(--color-accent-warm);
|
||||
opacity: 0.85;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update card date stamps to amber**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/KnowledgeView.vue
|
||||
git commit -m "feat(knowledge): card type DNA, violet hover bloom, amber timestamps, narrator empty states"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ChatPanel + BriefingView + CalendarView — empty states + glow buttons
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ChatPanel.vue`
|
||||
- Modify: `frontend/src/views/BriefingView.vue`
|
||||
- Modify: `frontend/src/views/CalendarView.vue`
|
||||
- Modify: `frontend/src/App.vue`
|
||||
|
||||
- [ ] **Step 1: Update ChatPanel empty state**
|
||||
|
||||
In `frontend/src/components/ChatPanel.vue`, find:
|
||||
```html
|
||||
>Send a message to start the conversation.</p>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```html
|
||||
>Start a conversation.</p>
|
||||
```
|
||||
|
||||
Find the `.empty-msg` CSS:
|
||||
```css
|
||||
.empty-msg {
|
||||
```
|
||||
|
||||
Add these properties (find the existing block and add to it):
|
||||
```css
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add scroll fade to ChatPanel messages**
|
||||
|
||||
Find the `.messages-container` CSS in ChatPanel.vue. Add:
|
||||
```css
|
||||
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update CalendarView empty state**
|
||||
|
||||
In `frontend/src/views/CalendarView.vue`, find:
|
||||
```html
|
||||
return ListView(
|
||||
children: const [
|
||||
SizedBox(height: 80),
|
||||
Center(child: Text('No events')),
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
Wait — that's the Flutter file. In the web CalendarView, there's no dedicated empty state text to update since it's a FullCalendar component. Skip this for the web CalendarView — it doesn't have a custom empty state.
|
||||
|
||||
- [ ] **Step 4: Add glow to primary action buttons in App.vue global styles**
|
||||
|
||||
In `frontend/src/App.vue`, find the `<style>` block (the global unscoped one). The `btn-send` styles are in `ChatInputBar.vue` which is scoped. Instead, add a global hover glow rule. Find the existing `.app-footer` style and add after it:
|
||||
|
||||
No — the glow should be on the specific button components. The `btn-send` in `ChatInputBar.vue` already has a hover shadow. Let me update it there.
|
||||
|
||||
In `frontend/src/components/ChatInputBar.vue`, find:
|
||||
```css
|
||||
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update KnowledgeView new-note button glow**
|
||||
|
||||
In `frontend/src/views/KnowledgeView.vue`, find:
|
||||
```css
|
||||
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.btn-new-note:hover { background: rgba(124, 58, 237, 0.2); box-shadow: 0 0 12px rgba(124, 58, 237, 0.25); }
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update any remaining hardcoded indigo references in KnowledgeView**
|
||||
|
||||
Search for `99, 102, 241` in KnowledgeView.vue and replace with `124, 58, 237`. This covers all the rgba references in filter buttons, borders, today bar chips, etc.
|
||||
|
||||
Use find-and-replace across the file: `99, 102, 241` → `124, 58, 237`
|
||||
|
||||
- [ ] **Step 7: Update hardcoded indigo in BriefingView**
|
||||
|
||||
Search for `99, 102, 241` in BriefingView.vue and replace with `124, 58, 237`.
|
||||
|
||||
Search for `6366f1` in BriefingView.vue and replace with `7c3aed`.
|
||||
|
||||
- [ ] **Step 8: Update hardcoded indigo in AppHeader**
|
||||
|
||||
Search for `99, 102, 241` in AppHeader.vue and replace with `124, 58, 237` (for any remaining references not covered by Task 2).
|
||||
|
||||
- [ ] **Step 9: Update hardcoded indigo in App.vue shortcuts overlay**
|
||||
|
||||
Search for `99, 102, 241` in App.vue and replace with `124, 58, 237`.
|
||||
|
||||
Search for `6366f1` in App.vue and replace with `7c3aed`.
|
||||
|
||||
- [ ] **Step 10: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ChatPanel.vue frontend/src/components/ChatInputBar.vue \
|
||||
frontend/src/views/KnowledgeView.vue frontend/src/views/BriefingView.vue \
|
||||
frontend/src/components/AppHeader.vue frontend/src/App.vue
|
||||
git commit -m "feat: narrator empty states, scroll fades, glow buttons, violet color sweep"
|
||||
```
|
||||
@@ -0,0 +1,144 @@
|
||||
# Knowledge View Task Consolidation — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Consolidate tasks into the Knowledge view as a card type, deprecate the standalone `/notes` and `/tasks` list views, and simplify navigation. The Knowledge view becomes the single hub for all content types: notes, tasks, people, places, and lists.
|
||||
|
||||
## Architecture
|
||||
|
||||
The Knowledge view already renders notes, people, places, and lists as typed cards in a filterable grid with a sidebar. Tasks are added as a fifth card type using the same two-tier pagination system (ID pre-fetch → content batch). The backend knowledge endpoints (`/api/knowledge/ids`, `/api/knowledge/batch`, `/api/knowledge/counts`) are extended to include tasks. No changes to the note/task CRUD API.
|
||||
|
||||
## Task Cards
|
||||
|
||||
Task cards follow the same layout as other knowledge cards:
|
||||
|
||||
- **Left accent strip**: distinct color for tasks (e.g. `#a78bfa` purple to differentiate from note indigo)
|
||||
- **Type badge**: "Task" in top-right corner
|
||||
- **Card body**:
|
||||
- Title (2-line clamp)
|
||||
- Status badge: `todo` / `in_progress` / `done` / `cancelled` — styled with existing status colors from theme (`--color-status-*`)
|
||||
- Priority indicator: shown only when priority is not `none` — uses existing priority colors (`--color-priority-*`)
|
||||
- Due date: shown when set, with overdue styling (`--color-overdue`) when past and status is not `done`/`cancelled`
|
||||
- **Card footer**: tags (up to 3) + last-modified date — identical to other card types
|
||||
|
||||
Clicking a task card navigates to `/tasks/:id/edit` (same as today).
|
||||
|
||||
## Filter Sidebar Changes
|
||||
|
||||
The type filter section gains a "Tasks" button:
|
||||
|
||||
```
|
||||
Type
|
||||
──────────
|
||||
[All] 127
|
||||
[Notes] 84
|
||||
[Tasks] 22
|
||||
[People] 8
|
||||
[Places] 5
|
||||
[Lists] 8
|
||||
```
|
||||
|
||||
The filter value for tasks is `type=task`. The backend already stores tasks as notes with `is_task=True`; the knowledge endpoints need to map the `type=task` filter to `is_task=True`.
|
||||
|
||||
## New Note Button Interaction
|
||||
|
||||
Current: click "New note" to create a note; chevron expands a dropdown with Note/Person/Place/List.
|
||||
|
||||
New behavior:
|
||||
|
||||
1. **Click "New note"** (when collapsed) → expands to reveal type options: Task, Person, Place, List. The main button label does not change.
|
||||
2. **Click "New note"** again (when expanded) → navigates to `/notes/new` (generic note).
|
||||
3. **Click any type option** → navigates to `/notes/new?type=<type>` (for task: `/notes/new?type=task`, which is equivalent to `/tasks/new`).
|
||||
4. **Click outside** → collapses the dropdown.
|
||||
|
||||
This replaces the current chevron split-button pattern with a simpler toggle. The dropdown items are: Task, Person, Place, List (no "Note" item in the dropdown — clicking the button itself creates a note).
|
||||
|
||||
## Route Changes
|
||||
|
||||
### Redirects
|
||||
|
||||
| Old route | New behavior |
|
||||
|-----------|-------------|
|
||||
| `/notes` | 302 redirect → `/` (Knowledge view) |
|
||||
| `/tasks` | 302 redirect → `/` (Knowledge view) |
|
||||
|
||||
### Preserved routes (no change)
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/notes/:id` | Note viewer |
|
||||
| `/notes/:id/edit` | Note editor |
|
||||
| `/notes/new` | New note (with optional `?type=` param) |
|
||||
| `/tasks/:id/edit` | Task editor |
|
||||
| `/tasks/new` | New task |
|
||||
|
||||
### Router implementation
|
||||
|
||||
Add redirect entries in the router config:
|
||||
|
||||
```ts
|
||||
{ path: '/notes', redirect: '/' },
|
||||
{ path: '/tasks', redirect: '/' },
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
Remove from `AppHeader.vue`:
|
||||
- "Tasks" nav link (`<router-link to="/tasks">`)
|
||||
- The `/tasks` entry in both desktop nav-center and mobile menu
|
||||
|
||||
Remove from `AppHeader.vue` (already done — `/notes` was removed in a prior change, but verify).
|
||||
|
||||
### Deleted files
|
||||
|
||||
- `frontend/src/views/NotesListView.vue`
|
||||
- `frontend/src/views/TasksListView.vue`
|
||||
- `frontend/src/stores/notes.ts` (if only used by NotesListView)
|
||||
- `frontend/src/stores/tasks.ts` (if only used by TasksListView)
|
||||
|
||||
Verify no other components import from these before deleting. The note/task viewer and editor screens import from `api/client.ts` directly, not from the list stores.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### `/api/knowledge/ids`
|
||||
|
||||
Accept `type=task` as a valid filter. When `type=task`, query `notes` table with `is_task = True`. When `type` is not set (all), include tasks in results alongside notes/people/places/lists.
|
||||
|
||||
### `/api/knowledge/batch`
|
||||
|
||||
Return task-specific fields for items where `is_task = True`:
|
||||
- `status`: todo / in_progress / done / cancelled
|
||||
- `priority`: none / low / normal / high
|
||||
- `due_date`: ISO date string or null
|
||||
|
||||
These are already columns on the `Note` model — just include them in the batch response when the item is a task.
|
||||
|
||||
### `/api/knowledge/counts`
|
||||
|
||||
Add `task` to the counts response:
|
||||
|
||||
```json
|
||||
{ "note": 84, "task": 22, "person": 8, "place": 5, "list": 8, "total": 127 }
|
||||
```
|
||||
|
||||
### `/api/knowledge/tags`
|
||||
|
||||
No change — tasks already have tags on the same `Note` model.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
Remove from `App.vue` `onGlobalKeydown`:
|
||||
- `case "t": router.push("/tasks/new")` — keep this, it still works
|
||||
- `case "g"` sequence `case "t": router.push("/tasks")` — change to `router.push("/")` (direct navigation, don't rely on redirect)
|
||||
|
||||
Update shortcuts overlay panel text if it references "Tasks list".
|
||||
|
||||
## No API Endpoint Changes
|
||||
|
||||
All existing REST endpoints remain:
|
||||
- `GET/POST /api/notes` — notes CRUD
|
||||
- `GET/POST /api/tasks` — tasks CRUD
|
||||
- `PATCH /api/notes/:id`, `PATCH /api/tasks/:id`
|
||||
- `DELETE /api/notes/:id`, `DELETE /api/tasks/:id`
|
||||
|
||||
MCP tools (`fable_create_task`, `fable_list_tasks`, etc.) are unaffected.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Specialized Note Type Editors — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the one-size-fits-all note editor with type-specialized views for Person, Place, and List. Each type gets a form-first layout where structured fields are the main content, with a secondary notes area for free text. Fix tab navigation across all note types so focus flows logically from title through fields to body, skipping the formatting toolbar.
|
||||
|
||||
## Architecture
|
||||
|
||||
The existing `NoteEditorView.vue` remains the single editor component but renders different layouts based on `noteType`. When `noteType` is `person`, `place`, or `list`, the main editor area switches from TipTap-first to form-first. The TipTap editor moves to a secondary "Notes" section below the form fields. The sidebar metadata fields for person/place move into the main content area. The `note_type` field, entity metadata storage, and API contract are unchanged.
|
||||
|
||||
## Person Editor
|
||||
|
||||
When `noteType === 'person'`, the main content area renders a contact card form instead of the TipTap editor.
|
||||
|
||||
### Fields (in order, all in main content area)
|
||||
|
||||
| Field | Type | Placeholder | Source |
|
||||
|-------|------|-------------|--------|
|
||||
| Name | text input (title) | "Name" | `title` |
|
||||
| Relationship | text input | "e.g. Friend, Colleague, Family" | `entityMeta.relationship` |
|
||||
| Birthday | date input | — | `entityMeta.birthday` (new field) |
|
||||
| Email | email input | "email@example.com" | `entityMeta.email` |
|
||||
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
|
||||
| Organization | text input | "Company or organization" | `entityMeta.organization` (new field) |
|
||||
| Address | text input | "Street, City, State" | `entityMeta.address` (new field for person) |
|
||||
|
||||
### Notes section
|
||||
|
||||
Below the form fields, a collapsible "Notes" section with the TipTap editor for free-text content. This is where wikilinks, tags, and general context go. The section starts expanded if the note already has body content, collapsed if empty on a new note.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [← Knowledge] [Save] [Delete] │
|
||||
│ │
|
||||
│ Name: [________________________________] │
|
||||
│ │
|
||||
│ Relationship: [________________________] │
|
||||
│ Birthday: [____date picker________] │
|
||||
│ Email: [________________________] │
|
||||
│ Phone: [________________________] │
|
||||
│ Organization: [________________________] │
|
||||
│ Address: [________________________] │
|
||||
│ │
|
||||
│ ▾ Notes │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ TipTap editor (markdown body) │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [sidebar: project/tags/etc] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data migration
|
||||
|
||||
Existing person notes may have structured data written as plain text in the body (e.g. "Relationship: daughter Birthday: 2013-12-13"). No automatic migration — the body content stays as-is in the Notes section. Users can move data to the structured fields manually.
|
||||
|
||||
## Place Editor
|
||||
|
||||
When `noteType === 'place'`, same form-first pattern.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Placeholder | Source |
|
||||
|-------|------|-------------|--------|
|
||||
| Name | text input (title) | "Place name" | `title` |
|
||||
| Address | text input | "Street, City, State" | `entityMeta.address` |
|
||||
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
|
||||
| Hours | text input | "e.g. Mon–Fri 9am–5pm" | `entityMeta.hours` |
|
||||
| Website | url input | "https://..." | `entityMeta.website` (new field) |
|
||||
| Category | text input | "e.g. Restaurant, Office, Doctor" | `entityMeta.category` (new field) |
|
||||
|
||||
### Notes section
|
||||
|
||||
Same as Person — collapsible TipTap editor below the form.
|
||||
|
||||
## List Editor
|
||||
|
||||
When `noteType === 'list'`, the main content area renders a checklist builder instead of the TipTap editor.
|
||||
|
||||
### List builder
|
||||
|
||||
Each list item is a row with:
|
||||
- Checkbox (toggle checked state)
|
||||
- Text input (item text, fills available width)
|
||||
- Delete button (× icon, right side)
|
||||
|
||||
Below the items: an "Add item" button.
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Enter** in any item input: creates a new item below and focuses it
|
||||
- **Backspace** on an empty item: deletes the item and focuses the previous one
|
||||
- **Checkbox toggle**: updates the item's checked state
|
||||
- **Delete button**: removes the item
|
||||
|
||||
### Serialization
|
||||
|
||||
On save, list items are serialized to markdown checkbox format in the body:
|
||||
```markdown
|
||||
- [ ] Buy groceries
|
||||
- [x] Call dentist
|
||||
- [ ] Pick up prescription
|
||||
```
|
||||
|
||||
On load, the body is parsed back into structured items (same parser already exists in `knowledge.py` and `KnowledgeView.vue`).
|
||||
|
||||
### Notes section
|
||||
|
||||
Same collapsible TipTap "Notes" section below the list builder, for additional context that isn't a list item.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [← Knowledge] [Save] [Delete] │
|
||||
│ │
|
||||
│ List title: [____________________________│
|
||||
│ │
|
||||
│ [ ] Buy groceries [×] │
|
||||
│ [x] Call dentist [×] │
|
||||
│ [ ] Pick up prescription [×] │
|
||||
│ │
|
||||
│ [+ Add item] │
|
||||
│ │
|
||||
│ ▾ Notes │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ TipTap editor (additional context) │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [sidebar: project/tags/etc] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Tab Navigation & Auto-Focus
|
||||
|
||||
### All note types
|
||||
|
||||
1. **On page load**: focus the title/name input automatically
|
||||
2. **Tab from title**: skip the formatting toolbar entirely, go to the first content field:
|
||||
- Note: TipTap editor body
|
||||
- Person: Relationship field
|
||||
- Place: Address field
|
||||
- List: first list item (or "Add item" button if empty)
|
||||
3. **Tab through fields**: natural order through all form fields
|
||||
4. **Tab from last form field**: enter the Notes section (TipTap editor)
|
||||
|
||||
### Implementation
|
||||
|
||||
Set `tabindex="-1"` on all MarkdownToolbar buttons so they are clickable but not in the tab order. The toolbar remains fully functional via mouse/touch — it's just skipped when tabbing.
|
||||
|
||||
### Title placeholder by type
|
||||
|
||||
| Type | Placeholder |
|
||||
|------|-------------|
|
||||
| Note | "Title" |
|
||||
| Person | "Name" |
|
||||
| Place | "Place name" |
|
||||
| List | "List title" |
|
||||
| Task | "Title" (unchanged, task editor is separate) |
|
||||
|
||||
## Sidebar changes
|
||||
|
||||
When editing a Person or Place, the type-specific metadata fields (Relationship, Email, Phone, etc.) **move from the sidebar to the main content area**. The sidebar keeps: Project, Milestone, Tags, Suggest Tags, Type selector, Link Suggestions, Writing Assistant, Version History.
|
||||
|
||||
The Type selector remains in the sidebar so users can change the type if needed. Changing type switches the layout.
|
||||
|
||||
## Backend changes
|
||||
|
||||
### New entity metadata fields
|
||||
|
||||
The `entity_meta` JSON column on the Note model already stores arbitrary key-value pairs. No schema migration needed — just store the new keys:
|
||||
|
||||
- Person: `birthday`, `organization`, `address` (new; `relationship`, `email`, `phone` existing)
|
||||
- Place: `website`, `category` (new; `address`, `phone`, `hours` existing)
|
||||
|
||||
### Knowledge service
|
||||
|
||||
Update `_note_to_item` in `services/knowledge.py` to include the new fields in the response for person and place cards:
|
||||
|
||||
- Person: add `birthday`, `organization`, `address`
|
||||
- Place: add `website`, `category`
|
||||
|
||||
### Knowledge card display
|
||||
|
||||
Update `KnowledgeView.vue` card rendering to show the new fields where useful (e.g. organization on person cards, category on place cards).
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/views/NoteEditorView.vue` | Type-conditional layouts, form fields, list builder, tab navigation, auto-focus, title placeholders |
|
||||
| `frontend/src/views/KnowledgeView.vue` | Card display for new person/place fields |
|
||||
| `frontend/src/components/MarkdownToolbar.vue` | `tabindex="-1"` on all buttons |
|
||||
| `src/fabledassistant/services/knowledge.py` | New fields in `_note_to_item` for person/place |
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- Note model / database schema (entity_meta is already a JSON column)
|
||||
- API endpoints (same CRUD)
|
||||
- Task editor (`TaskEditorView.vue`) — separate component, unchanged
|
||||
- Generic note editing — TipTap-first layout stays for `noteType === 'note'`
|
||||
- Backend storage format — entity_meta key-value pairs
|
||||
@@ -0,0 +1,249 @@
|
||||
# Modern Fable — Visual Identity Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the generic "competent dark-mode Vue app" aesthetic with a distinctive visual identity that is unmistakably Fabled Assistant. The design language evolves from "Illuminated Transcript" to "Modern Fable" — keeping the scholarly DNA but adding personality through color, typography, interaction, and card design that no other app has.
|
||||
|
||||
## Color Palette
|
||||
|
||||
Shift from indigo (`#6366f1`) to deep violet + muted gold.
|
||||
|
||||
### Dark theme
|
||||
|
||||
| Role | Old | New | Usage |
|
||||
|------|-----|-----|-------|
|
||||
| Primary | `#818cf8` | `#a78bfa` | Text accents, active states, tags, links |
|
||||
| Primary solid | `#6366f1` | `#7c3aed` | Buttons, gradients, accent strips |
|
||||
| Primary deep | `#4f46e5` | `#5b21b6` | Gradient endpoints, hover states |
|
||||
| Accent (warm) | — | `#d4a017` | Due dates, event times, counts, temporal data |
|
||||
| Accent light | — | `#e8c45a` | Amber hover states |
|
||||
| Background | `#111113` | `#0f0f14` | Slightly deeper, more dramatic |
|
||||
| Surface | `#1a1b22` | `#16161f` | Cards, panels |
|
||||
| Card bg | `#1e1e27` | `#1a1a24` | Card interiors |
|
||||
| Border | `rgba(99,102,241,0.10)` | `rgba(124,58,237,0.12)` | Violet-tinted borders |
|
||||
| Text | `#e4e4f0` | `#e4e4f0` | Unchanged |
|
||||
| Text muted | `#52526a` | `#52526a` | Unchanged |
|
||||
|
||||
### Light theme
|
||||
|
||||
| Role | Old | New |
|
||||
|------|-----|-----|
|
||||
| Primary | `#6366f1` | `#7c3aed` |
|
||||
| Primary text | `#4f46e5` | `#5b21b6` |
|
||||
| Accent | — | `#b8860b` (darker gold for light bg) |
|
||||
| Tag bg | `#ede9fe` | `#ede5ff` |
|
||||
| Tag text | `#4f46e5` | `#6d28d9` |
|
||||
|
||||
### Semantic color rules
|
||||
|
||||
- **Violet = structural** — navigation, type badges, status indicators, card accents, CTA buttons
|
||||
- **Amber/gold = temporal** — due dates, event times, countdown values, "overdue" states, calendar dot, relative timestamps
|
||||
- This duality is a core brand principle: violet organizes, amber marks time
|
||||
|
||||
### Logo update
|
||||
|
||||
Update `AppLogo.vue` SVG fill to use the new violet gradient (`#7c3aed` → `#5b21b6`) instead of the current indigo values.
|
||||
|
||||
## Card Design — Type DNA
|
||||
|
||||
Each content type gets a distinct visual signature recognizable at a glance without reading the badge.
|
||||
|
||||
### Shared card structure
|
||||
|
||||
- Background: `var(--color-surface)`
|
||||
- Border: `1px solid` with type-tinted color at low opacity
|
||||
- Border-radius: `var(--radius-lg)` (14px)
|
||||
- Padding: 14px
|
||||
- Hover: translateY(-2px) + violet shadow bloom (`0 8px 24px rgba(124,58,237,0.15)`)
|
||||
|
||||
### Type-specific signatures
|
||||
|
||||
**Note** (`note`)
|
||||
- Top edge: full-width 3px gradient bar (`#7c3aed` → `#a78bfa`)
|
||||
- Border tint: `rgba(124,58,237,0.12)`
|
||||
- Badge color: `#a78bfa`
|
||||
|
||||
**Task** (`task`)
|
||||
- Top edge: half-width 3px gradient bar (`#d4a017` → transparent`), left-aligned — partial bar suggests "in progress"
|
||||
- Border tint: `rgba(212,160,23,0.10)`
|
||||
- Badge color: `#d4a017`
|
||||
- Status badge inline with type badge row
|
||||
- Due date in amber; overdue in `--color-overdue` (red)
|
||||
|
||||
**Person** (`person`)
|
||||
- Top edge: none
|
||||
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(16,185,129,0.06)`)
|
||||
- Border tint: `rgba(16,185,129,0.10)`
|
||||
- Badge color: `#34d399`
|
||||
|
||||
**Place** (`place`)
|
||||
- Top edge: none
|
||||
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(245,158,11,0.06)`)
|
||||
- Border tint: `rgba(245,158,11,0.10)`
|
||||
- Badge color: `#fbbf24`
|
||||
|
||||
**List** (`list`)
|
||||
- Top edge: full-width 3px gradient bar (`#38bdf8` → `#7dd3fc`)
|
||||
- Border tint: `rgba(56,189,248,0.10)`
|
||||
- Badge color: `#7dd3fc`
|
||||
- Progress bar beneath checkboxes
|
||||
|
||||
### Card hover state
|
||||
|
||||
All cards share the same hover treatment:
|
||||
```css
|
||||
.k-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(124,58,237,0.15), 0 2px 8px rgba(0,0,0,0.3);
|
||||
border-color: rgba(124,58,237,0.2);
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation — Signature Header
|
||||
|
||||
Replace the flat nav links with a pill-grouped tab bar.
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
[Logo + "Fabled"] [ Knowledge | Chat | Briefing | Calendar | News | Projects ] [status · ? · ☀ · ⚙ · user]
|
||||
```
|
||||
|
||||
### Brand in header
|
||||
|
||||
- Logo: `AppLogo` SVG at 28px with new violet gradient
|
||||
- Text: "Fabled" only (not "Fabled Assistant") — Fraunces italic, `#c4b0f0`, 15px
|
||||
- The full name "Fabled Assistant" appears on the login page and Settings; the header uses the short form
|
||||
|
||||
### Tab bar
|
||||
|
||||
- Container: `rgba(124,58,237,0.06)` background, `border-radius: 10px`, 3px padding
|
||||
- Inactive tabs: transparent background, `color: var(--color-text-muted)`
|
||||
- Active tab: `rgba(124,58,237,0.2)` background, `border-radius: 8px`, `color: #c4b5fd`, soft box-shadow glow `0 0 12px rgba(124,58,237,0.2)`
|
||||
- Hover (inactive): `rgba(124,58,237,0.08)` background
|
||||
- Transition: background 0.15s, color 0.15s
|
||||
|
||||
### Header background
|
||||
|
||||
Subtle gradient: `linear-gradient(180deg, var(--color-surface), var(--color-bg))` with a bottom border of `rgba(124,58,237,0.08)`. Creates depth without being heavy.
|
||||
|
||||
### Mobile
|
||||
|
||||
On mobile (< 768px), the pill bar collapses into the existing hamburger dropdown menu. The dropdown gets the same violet active styling.
|
||||
|
||||
## Typography — Fraunces as Narrator
|
||||
|
||||
Fraunces italic becomes the "narrator's voice" of the application — the assistant speaking through the UI. System UI font remains for body text and interactive elements.
|
||||
|
||||
### Where Fraunces is used
|
||||
|
||||
| Element | Style | Example |
|
||||
|---------|-------|---------|
|
||||
| View titles | Fraunces italic, 20-24px, `#c4b0f0` | *Knowledge*, *Chat*, *Briefing* |
|
||||
| Sidebar section labels | Fraunces italic, 11px, `var(--color-primary)` | *Filter*, *Tags*, *Sort* |
|
||||
| Empty states | Fraunces italic, 13-15px, `#d4a017` | *"Every story starts with a blank page."* |
|
||||
| Card headings (h1/h2/h3) | Fraunces, non-italic, 600 weight | Existing behavior, unchanged |
|
||||
| Briefing greeting | Fraunces italic, 16px | *"Good morning, Bryan"* |
|
||||
|
||||
### Where Fraunces is NOT used
|
||||
|
||||
- Navigation tab labels (system font, 12-13px)
|
||||
- Buttons and form labels
|
||||
- Card body text, snippets, metadata
|
||||
- Toast messages, error text
|
||||
|
||||
### Empty state voice
|
||||
|
||||
Each major view gets a distinctive empty state message in Fraunces italic, amber color:
|
||||
- Knowledge: *"Your story is unwritten. Create your first note to begin."*
|
||||
- Chat: *"Start a conversation."*
|
||||
- Calendar: *"No events ahead. A quiet chapter."*
|
||||
- Briefing (no briefing yet): *"Your daily briefing will appear here each morning."*
|
||||
|
||||
## Living Details
|
||||
|
||||
Small touches that accumulate into a distinctive feel.
|
||||
|
||||
### Glow interactions
|
||||
|
||||
- **Buttons**: Primary buttons (`btn-send`, `btn-new-note`, CTAs) get a violet glow on hover: `box-shadow: 0 0 16px rgba(124,58,237,0.35)`
|
||||
- **Focus ring**: Change from current `color-mix` to a violet glow: `0 0 0 2px rgba(124,58,237,0.4)`
|
||||
- **Active nav tab**: Soft glow behind the active pill (see Navigation section)
|
||||
|
||||
### Amber for temporal data
|
||||
|
||||
Consistently use `#d4a017` (dark theme) for all time-related information:
|
||||
- Due dates on task cards
|
||||
- Event times on calendar chips
|
||||
- "3d ago" timestamps on cards
|
||||
- Overdue badge in the today bar
|
||||
- Countdown/relative time in briefing
|
||||
|
||||
This creates a visual language: when you see amber, it's about *when*.
|
||||
|
||||
### Card hover bloom
|
||||
|
||||
Cards lift and emit a violet shadow on hover (see Card Design section). The shadow color matches the card's type accent at very low opacity for a subtle differentiation.
|
||||
|
||||
### Status dot pulse
|
||||
|
||||
The Ollama status indicator in the header gains a CSS pulse animation when the model is loaded:
|
||||
```css
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(74,222,128,0.4); }
|
||||
50% { box-shadow: 0 0 10px rgba(74,222,128,0.6); }
|
||||
}
|
||||
```
|
||||
Pulse only when status is "loaded" (green). Offline (red) and loading (amber) are static.
|
||||
|
||||
### Scroll edge fades
|
||||
|
||||
Top and bottom edges of scrollable areas (card grid, chat messages, sidebar tag list) get a gradient mask that fades content into the background. 20px height, using `mask-image: linear-gradient(...)`.
|
||||
|
||||
### Sidebar section dividers
|
||||
|
||||
Replace flat `border-bottom` between filter sections with a centered ornamental divider:
|
||||
```css
|
||||
.filter-section + .filter-section::before {
|
||||
content: '·';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: rgba(124,58,237,0.3);
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 0.5em;
|
||||
padding: 8px 0;
|
||||
}
|
||||
```
|
||||
Three centered dots (` · · · `) in faint violet. Subtle but distinctive.
|
||||
|
||||
### Scrollbar
|
||||
|
||||
Keep the current thin scrollbar but update the color from indigo to violet:
|
||||
```css
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124,58,237,0.25);
|
||||
}
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/assets/theme.css` | Full palette update (both light and dark), scrollbar color |
|
||||
| `frontend/src/components/AppLogo.vue` | SVG fill gradient update |
|
||||
| `frontend/src/components/AppHeader.vue` | Pill-grouped nav tabs, brand shortening, header gradient, status pulse |
|
||||
| `frontend/src/views/KnowledgeView.vue` | Card type DNA (gradient bars, corner accents), hover bloom, section dividers, empty state text, scroll fades, Fraunces view title |
|
||||
| `frontend/src/components/ChatPanel.vue` | Scroll fade on messages, empty state text |
|
||||
| `frontend/src/views/CalendarView.vue` | Empty state text, amber event times |
|
||||
| `frontend/src/views/BriefingView.vue` | Empty state text, Fraunces greeting |
|
||||
| `frontend/src/views/ChatView.vue` | (uses ChatPanel — inherits changes) |
|
||||
| `frontend/src/App.vue` | Update any global styles referencing old indigo values |
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- Overall layout structure (sidebar + content + optional graph panel)
|
||||
- Chat bubble design (user transparent, assistant border-left + shadow)
|
||||
- TipTap editor styling
|
||||
- Settings view layout
|
||||
- Backend — zero changes
|
||||
- Mobile layout patterns
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -90,7 +90,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case "h": router.push("/"); break;
|
||||
case "n": router.push("/notes"); break;
|
||||
case "t": router.push("/tasks"); break;
|
||||
case "t": router.push("/"); break;
|
||||
case "p": router.push("/projects"); break;
|
||||
case "c": router.push("/chat"); break;
|
||||
case "g": router.push("/graph"); break;
|
||||
@@ -202,7 +202,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>
|
||||
|
||||
@@ -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,50 @@
|
||||
--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;
|
||||
--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 +100,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 +191,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) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -391,7 +391,7 @@ defineExpose({ focus, prefill })
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
@@ -400,7 +400,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -208,7 +208,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 +249,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')
|
||||
@@ -289,6 +289,23 @@ async function send(text: string) {
|
||||
await onSubmit({ content: text })
|
||||
}
|
||||
|
||||
// ── 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 })
|
||||
</script>
|
||||
|
||||
@@ -303,13 +320,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"
|
||||
@@ -338,7 +353,7 @@ defineExpose({ focus, prefill, send })
|
||||
<p
|
||||
v-if="!store.currentConversation?.messages.length && !store.streaming"
|
||||
class="empty-msg"
|
||||
>Send a message to start the conversation.</p>
|
||||
>Start a conversation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -525,6 +540,8 @@ defineExpose({ focus, prefill, send })
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
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,24 +552,10 @@ 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 */
|
||||
@@ -746,6 +749,9 @@ 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);
|
||||
}
|
||||
|
||||
/* ── Widget variant ── */
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -40,7 +40,7 @@ defineEmits<{
|
||||
}
|
||||
.tag-pill:hover {
|
||||
color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.dismiss {
|
||||
background: none;
|
||||
|
||||
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -60,6 +60,27 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
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 +111,7 @@ async function loadAll() {
|
||||
getBriefingToday().catch(() => null),
|
||||
loadWeather(),
|
||||
loadNews(),
|
||||
loadCurrentConditions(),
|
||||
])
|
||||
conversations.value = convList
|
||||
if (today) {
|
||||
@@ -198,11 +220,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>
|
||||
|
||||
@@ -235,6 +264,7 @@ onMounted(async () => {
|
||||
<!-- Left column: Weather -->
|
||||
<div class="briefing-left">
|
||||
<div class="panel-label">Weather</div>
|
||||
<!-- Forecast card -->
|
||||
<template v-if="weatherData.length">
|
||||
<WeatherCard
|
||||
v-for="loc in weatherData"
|
||||
@@ -457,6 +487,8 @@ onMounted(async () => {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Current conditions (live) ─────────────────────────── */
|
||||
|
||||
.panel-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -639,6 +639,9 @@ onUnmounted(() => {
|
||||
gap: 1rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.no-conversation .btn-new-conv {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. Mon–Fri 9am–5pm" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Website</label>
|
||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Category</label>
|
||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── List builder ─────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'list'">
|
||||
<div class="list-builder">
|
||||
<div
|
||||
v-for="(item, idx) in listItems"
|
||||
:key="idx"
|
||||
class="lb-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.checked"
|
||||
@change="toggleListItemCheck(idx)"
|
||||
class="lb-check"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
||||
v-model="item.text"
|
||||
class="lb-text"
|
||||
placeholder="List item..."
|
||||
@keydown="onListItemKeydown($event, idx)"
|
||||
@input="onListItemInput(idx)"
|
||||
/>
|
||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
||||
</div>
|
||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Generic note editor ──────────────────────────────── -->
|
||||
<template v-else>
|
||||
<div class="body-tabs-row">
|
||||
<div class="editor-tabs">
|
||||
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
|
||||
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
|
||||
</div>
|
||||
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
|
||||
</div>
|
||||
|
||||
<!-- Streaming preview -->
|
||||
<template v-if="assist.state.value === 'streaming'">
|
||||
<div class="stream-label">Generating...</div>
|
||||
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
|
||||
</template>
|
||||
|
||||
<!-- Review: full-document diff -->
|
||||
<template v-else-if="assist.state.value === 'review'">
|
||||
<DiffView :diff="assist.diff.value" class="main-diff" />
|
||||
</template>
|
||||
|
||||
<!-- Normal editor -->
|
||||
<template v-else>
|
||||
<Transition name="tab-fade" mode="out-in">
|
||||
<div v-if="!showPreview" class="body-editor-wrap">
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Write your note in Markdown..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
/>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- Error from assist -->
|
||||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── Sidebar ──────────────────────────────────────────── -->
|
||||
@@ -476,38 +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. Mon–Fri 9–5" @input="markDirty" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Link Suggestions -->
|
||||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||||
<div class="sb-label-row">
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||
import SearchBar from "@/components/SearchBar.vue";
|
||||
import NoteCard from "@/components/NoteCard.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
|
||||
type ViewMode = "grid" | "list";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useNotesStore();
|
||||
|
||||
const searchBarRef = ref<{ focus: () => void } | null>(null);
|
||||
|
||||
function onFocusSearch() {
|
||||
searchBarRef.value?.focus();
|
||||
}
|
||||
|
||||
const { activeIndex } = useListKeyboardNavigation(
|
||||
computed(() => store.notes),
|
||||
(note) => router.push(`/notes/${note.id}`),
|
||||
);
|
||||
|
||||
const viewMode = ref<ViewMode>(
|
||||
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
|
||||
);
|
||||
|
||||
function setViewMode(mode: ViewMode) {
|
||||
viewMode.value = mode;
|
||||
localStorage.setItem("fabled-notes-view-mode", mode);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const tag = route.query.tag;
|
||||
if (tag) {
|
||||
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
|
||||
store.setTagFilters(tags);
|
||||
} else {
|
||||
store.refresh();
|
||||
}
|
||||
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("shortcut:focus-search", onFocusSearch);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.tag,
|
||||
(tag) => {
|
||||
if (tag) {
|
||||
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
|
||||
store.setTagFilters(tags);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function onSearch(q: string) {
|
||||
store.setSearch(q);
|
||||
}
|
||||
|
||||
function onTagClick(tag: string) {
|
||||
store.addTagFilter(tag);
|
||||
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
|
||||
}
|
||||
|
||||
function onTagDismiss(tag: string) {
|
||||
store.removeTagFilter(tag);
|
||||
const newQuery = { ...route.query };
|
||||
if (store.activeTagFilters.length > 0) {
|
||||
newQuery.tag = store.activeTagFilters;
|
||||
} else {
|
||||
delete newQuery.tag;
|
||||
}
|
||||
router.replace({ query: newQuery });
|
||||
}
|
||||
|
||||
function onSortChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
store.setSort(value, store.sortOrder);
|
||||
}
|
||||
|
||||
function toggleOrder() {
|
||||
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
|
||||
}
|
||||
|
||||
function onOffsetUpdate(offset: number) {
|
||||
store.setOffset(offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="notes-list">
|
||||
<div class="header">
|
||||
<h1>Notes</h1>
|
||||
<router-link to="/notes/new" class="btn-new">+ New Note</router-link>
|
||||
</div>
|
||||
<SearchBar ref="searchBarRef" @search="onSearch" />
|
||||
|
||||
<div class="controls">
|
||||
<div class="sort-controls">
|
||||
<select :value="store.sortField" @change="onSortChange" class="sort-select">
|
||||
<option value="updated_at">Updated</option>
|
||||
<option value="created_at">Created</option>
|
||||
<option value="title">Title</option>
|
||||
</select>
|
||||
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
|
||||
{{ store.sortOrder === "asc" ? "↑" : "↓" }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- View mode toggle -->
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
:class="['toggle-btn', { active: viewMode === 'grid' }]"
|
||||
title="Grid view"
|
||||
@click="setViewMode('grid')"
|
||||
>⊞</button>
|
||||
<button
|
||||
:class="['toggle-btn', { active: viewMode === 'list' }]"
|
||||
title="Compact list"
|
||||
@click="setViewMode('list')"
|
||||
>☰</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.activeTagFilters.length" class="active-filters">
|
||||
<span class="filter-label">Filtering by:</span>
|
||||
<TagPill
|
||||
v-for="tag in store.activeTagFilters"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
dismissible
|
||||
@dismiss="onTagDismiss"
|
||||
/>
|
||||
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="note-list-skeleton">
|
||||
<div v-if="viewMode === 'grid'" class="skeleton-grid">
|
||||
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
|
||||
</div>
|
||||
<div v-else class="skeleton-rows">
|
||||
<div class="skeleton-row" v-for="i in 8" :key="i"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="store.notes.length === 0" class="empty-state">
|
||||
<template v-if="store.searchQuery || store.activeTagFilters.length">
|
||||
<p class="empty-title">No notes match your filters</p>
|
||||
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="empty-title">No notes yet</p>
|
||||
<p class="empty-subtitle">Create your first note to get started.</p>
|
||||
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Grid view -->
|
||||
<div v-else-if="viewMode === 'grid'" class="cards-grid">
|
||||
<div
|
||||
v-for="(note, i) in store.notes"
|
||||
:key="note.id"
|
||||
:class="{ 'kb-active-item': activeIndex === i }"
|
||||
>
|
||||
<NoteCard :note="note" @tag-click="onTagClick" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact list view -->
|
||||
<div v-else class="cards-list">
|
||||
<div
|
||||
v-for="(note, i) in store.notes"
|
||||
:key="note.id"
|
||||
:class="{ 'kb-active-item': activeIndex === i }"
|
||||
>
|
||||
<NoteCard :note="note" compact @tag-click="onTagClick" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:total="store.total"
|
||||
:limit="store.limit"
|
||||
:offset="store.offset"
|
||||
@update:offset="onOffsetUpdate"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notes-list {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.btn-new {
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-new:hover {
|
||||
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sort-select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sort-order {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.toggle-btn {
|
||||
padding: 0.3rem 0.55rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.active-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.clear-filters {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-danger);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.note-list-skeleton {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 140px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
.skeleton-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.skeleton-row {
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Grid layout */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.cards-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Compact list layout */
|
||||
.cards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.empty-subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-cta {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-cta:hover {
|
||||
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.kb-active-item {
|
||||
outline: 2px solid var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
@@ -20,7 +20,7 @@ interface Project {
|
||||
title: string;
|
||||
description: string | null;
|
||||
goal: string | null;
|
||||
status: "active" | "completed" | "archived";
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
auto_summary: string | null;
|
||||
permission?: string;
|
||||
@@ -40,7 +40,7 @@ const toast = useToastStore();
|
||||
const projects = ref<Project[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const activeTab = ref<"all" | "active" | "completed" | "archived">("all");
|
||||
const activeTab = ref<"all" | "active" | "paused" | "completed" | "archived">("all");
|
||||
|
||||
// New project modal
|
||||
const showNewProjectModal = ref(false);
|
||||
@@ -103,6 +103,7 @@ async function createProject() {
|
||||
|
||||
function statusLabel(status: Project["status"]): string {
|
||||
if (status === "active") return "Active";
|
||||
if (status === "paused") return "Paused";
|
||||
if (status === "completed") return "Completed";
|
||||
if (status === "archived") return "Archived";
|
||||
return status;
|
||||
@@ -112,6 +113,14 @@ function truncate(text: string | null, max = 120): string {
|
||||
if (!text) return "";
|
||||
return text.length > max ? text.slice(0, max) + "..." : text;
|
||||
}
|
||||
|
||||
function overallPct(project: Project): { total: number; pct: number } {
|
||||
const counts = project.summary?.task_counts;
|
||||
if (!counts) return { total: 0, pct: 0 };
|
||||
const total = (counts.todo ?? 0) + (counts.in_progress ?? 0) + (counts.done ?? 0);
|
||||
const pct = total > 0 ? Math.round((counts.done ?? 0) / total * 100) : 0;
|
||||
return { total, pct };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -124,7 +133,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
<!-- Filter tabs -->
|
||||
<div class="filter-tabs">
|
||||
<button
|
||||
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
|
||||
v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
|
||||
:key="tab"
|
||||
:class="['tab-btn', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -163,6 +172,18 @@ function truncate(text: string | null, max = 120): string {
|
||||
</p>
|
||||
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
|
||||
|
||||
<!-- Overall completion bar -->
|
||||
<div
|
||||
v-if="overallPct(project).total > 0"
|
||||
class="overall-bar-row"
|
||||
:title="`${project.summary?.task_counts.done ?? 0} of ${overallPct(project).total} tasks complete`"
|
||||
>
|
||||
<div class="overall-bar-track">
|
||||
<div class="overall-bar-fill" :style="{ width: overallPct(project).pct + '%' }"></div>
|
||||
</div>
|
||||
<span class="overall-bar-pct">{{ overallPct(project).pct }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Milestone progress bars -->
|
||||
<div
|
||||
v-if="project.summary?.milestone_summary?.length"
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ────────────────────────────────────────────── */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -2529,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=<your-api-key></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">
|
||||
@@ -3024,7 +3140,7 @@ FABLE_API_KEY=<your-api-key></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;
|
||||
}
|
||||
@@ -3489,7 +3605,7 @@ FABLE_API_KEY=<your-api-key></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;
|
||||
@@ -4215,6 +4331,54 @@ FABLE_API_KEY=<your-api-key></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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:3b")
|
||||
OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "gemma3:4b")
|
||||
# Ollama keep_alive — how long a model stays resident in VRAM after its last
|
||||
# request. Main model gets a longer window since it's used interactively;
|
||||
# the background model is called sporadically and doesn't need to camp VRAM.
|
||||
# Format matches Ollama's duration strings: "30m", "10m", "1h", "0s", "-1" (forever).
|
||||
OLLAMA_KEEP_ALIVE_MAIN: str = os.environ.get("OLLAMA_KEEP_ALIVE_MAIN", "30m")
|
||||
OLLAMA_KEEP_ALIVE_BACKGROUND: str = os.environ.get("OLLAMA_KEEP_ALIVE_BACKGROUND", "10m")
|
||||
# KV cache context window for generation. Keep this as small as practical —
|
||||
# a larger context forces more KV cache into CPU RAM, drastically slowing prefill.
|
||||
# 16384 covers ~30+ message conversations with our system prompt comfortably.
|
||||
|
||||
@@ -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"])
|
||||
@@ -502,7 +581,108 @@ async def discuss_article(item_id: int):
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title or "",
|
||||
"Please summarize and discuss this article.",
|
||||
think=True,
|
||||
))
|
||||
|
||||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
||||
|
||||
|
||||
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def discuss_topic(topic: str):
|
||||
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
|
||||
data = await request.get_json() or {}
|
||||
conv_id = data.get("conv_id")
|
||||
if not conv_id:
|
||||
return jsonify({"error": "conv_id required"}), 400
|
||||
|
||||
uid = g.user.id
|
||||
|
||||
# Verify conversation belongs to user
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
if get_buffer(conv_id) is not None:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
# Find recent articles with this topic (last 2 days)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem)
|
||||
.join(RssFeed, RssFeed.id == RssItem.feed_id)
|
||||
.where(RssFeed.user_id == uid)
|
||||
.where(RssItem.topics.contains([topic]))
|
||||
.order_by(RssItem.published_at.desc().nullslast())
|
||||
.limit(5)
|
||||
)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
if not items:
|
||||
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
|
||||
|
||||
# Fetch full content for each article
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
|
||||
synthetic_tool_calls = []
|
||||
for item in items:
|
||||
content = await _fetch_full_article(item.url) if item.url else None
|
||||
content = content or item.content or ""
|
||||
synthetic_tool_calls.append({
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url or ""},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url or "",
|
||||
"title": item.title or "",
|
||||
"source": "",
|
||||
"content": content[:8000], # cap per article to stay within context
|
||||
"truncated": len(content) > 8000,
|
||||
},
|
||||
})
|
||||
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
|
||||
user_prompt = (
|
||||
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
|
||||
"Don't just summarize each one — draw connections between the sources, "
|
||||
"highlight where they agree or disagree, and share your analysis of what "
|
||||
"this means. Let's have a real discussion about this topic."
|
||||
)
|
||||
await add_message(conv_id, "user", user_prompt)
|
||||
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
assert conv is not None
|
||||
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title or "",
|
||||
user_prompt,
|
||||
))
|
||||
|
||||
return jsonify({
|
||||
"assistant_message_id": assistant_msg.id,
|
||||
"status": "generating",
|
||||
"article_count": len(items),
|
||||
}), 202
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -37,72 +37,77 @@ _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
|
||||
# Thinking decision
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `_should_think` is the single source of truth for whether a qwen3-class
|
||||
# model should engage chain-of-thought for a given request. Frontend callers
|
||||
# should NOT hardcode think=True — leave it False and let the classifier
|
||||
# decide from message content. An explicit think_requested=True still acts
|
||||
# as an override for callers (e.g. a future UI toggle or MCP client) that
|
||||
# want to force extended reasoning regardless of content.
|
||||
#
|
||||
# Why gate it: on qwen3:14b, thinking adds 5–20s of latency before the first
|
||||
# visible content token, and most conversational messages do not benefit.
|
||||
# Gating by content keeps quick chats fast while preserving reasoning depth
|
||||
# for prompts that actually need it.
|
||||
#
|
||||
# Models that don't support extended reasoning (e.g. llama3, mistral) simply
|
||||
# ignore the `think` parameter in the Ollama chat request, so the decision
|
||||
# here is harmless on non-thinking models.
|
||||
|
||||
# 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,
|
||||
|
||||
# Keywords that strongly suggest the user wants reasoning / analysis. Matched
|
||||
# case-insensitively as whole-ish phrases.
|
||||
_THINK_KEYWORDS: tuple[str, ...] = (
|
||||
"why", "how does", "how do i", "how would", "how should",
|
||||
"explain", "analyze", "analyse", "compare", "contrast",
|
||||
"design", "architect", "architecture", "plan out", "strategize",
|
||||
"debug", "diagnose", "troubleshoot", "root cause",
|
||||
"review", "critique", "evaluate", "trade-off", "tradeoff", "trade off",
|
||||
"pros and cons", "step by step", "walk me through",
|
||||
"prove", "derive", "figure out", "work through",
|
||||
"discuss", # covers briefing /discuss-article + /discuss-topic entry points
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
# Messages shorter than this and without any think-keyword are treated as
|
||||
# simple/conversational and skip the thinking phase.
|
||||
_SHORT_MESSAGE_CHARS = 80
|
||||
|
||||
_WORD_COUNT_THRESHOLD = 60 # messages over this word count always use think=True
|
||||
_SHORT_MESSAGE_THRESHOLD = 12 # messages under this always use think=False
|
||||
# Messages longer than this are treated as substantive regardless of keywords.
|
||||
_LONG_MESSAGE_CHARS = 400
|
||||
|
||||
|
||||
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.
|
||||
``think_requested`` acts as an explicit override: if True, thinking is
|
||||
forced on regardless of content. If False (the default), the decision is
|
||||
made by inspecting the message: long or keyword-bearing messages get
|
||||
thinking; short conversational messages skip it.
|
||||
"""
|
||||
if not think_requested:
|
||||
return False
|
||||
|
||||
text = user_content.strip()
|
||||
word_count = len(text.split())
|
||||
|
||||
if word_count <= _SHORT_MESSAGE_THRESHOLD:
|
||||
return False
|
||||
if _THINK_SKIP.match(text):
|
||||
return False
|
||||
if word_count >= _WORD_COUNT_THRESHOLD:
|
||||
if think_requested:
|
||||
return True
|
||||
if "```" in text:
|
||||
text = (user_content or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if len(text) >= _LONG_MESSAGE_CHARS:
|
||||
return True
|
||||
if _THINK_FORCE.search(text):
|
||||
lowered = text.lower()
|
||||
if any(kw in lowered for kw in _THINK_KEYWORDS):
|
||||
return True
|
||||
|
||||
if len(text) < _SHORT_MESSAGE_CHARS:
|
||||
return False
|
||||
# Medium-length message with no obvious reasoning cue: default off.
|
||||
return False
|
||||
|
||||
|
||||
# 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)",
|
||||
@@ -275,25 +280,32 @@ 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)
|
||||
# `_should_think` is authoritative — frontend callers pass think=False by
|
||||
# default and let this classifier decide based on message content. An
|
||||
# explicit think=True still forces on as an override.
|
||||
think_requested = think
|
||||
think = _should_think(user_content, think_requested)
|
||||
|
||||
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,
|
||||
@@ -325,11 +337,21 @@ async def run_generation(
|
||||
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:
|
||||
@@ -376,8 +398,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
|
||||
@@ -452,9 +475,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)
|
||||
@@ -500,9 +527,22 @@ async def run_generation(
|
||||
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
|
||||
|
||||
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:
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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,7 +263,7 @@ 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] = []
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
@@ -269,9 +317,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 +345,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,6 +566,8 @@ 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.",
|
||||
"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.",
|
||||
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes.",
|
||||
]
|
||||
if has_caldav:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 (3–8 entries).
|
||||
Returns [] on failure — callers must fall back to single-note synthesis.
|
||||
Returns a list of {"title": str, "focus": str} dicts (2–8 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"
|
||||
"- 2–3 paragraphs of substantive prose (150–300 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,
|
||||
|
||||
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}}
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Weather tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _wx_json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fabledassistant.services.tools._registry import tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_weather",
|
||||
description=(
|
||||
"Get the weather forecast. By default returns today only — use days=7 when the user "
|
||||
"explicitly asks for a multi-day forecast or 'this week'. "
|
||||
"Pass a city/region name to look up any location, or omit to get the user's configured home/work locations."
|
||||
),
|
||||
parameters={
|
||||
"location": {"type": "string", "description": "City/region to look up, or 'home'/'work' for saved locations. Omit for all saved."},
|
||||
"days": {"type": "integer", "description": "Number of days to return (1–8). Default 1 (today only). Use 7 or 8 when the user asks for a weekly forecast or multiple days."},
|
||||
},
|
||||
read_only=True,
|
||||
briefing=True,
|
||||
)
|
||||
async def get_weather_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.briefing_pipeline import _get_temp_unit
|
||||
from fabledassistant.services.weather import (
|
||||
_fetch_open_meteo,
|
||||
geocode,
|
||||
get_cached_weather,
|
||||
parse_forecast,
|
||||
refresh_location_cache,
|
||||
)
|
||||
|
||||
location = (arguments.get("location") or "").strip()
|
||||
num_days = max(1, min(8, int(arguments.get("days") or 1)))
|
||||
temp_unit = await _get_temp_unit(user_id)
|
||||
|
||||
def _apply_unit(days: list[dict]) -> list[dict]:
|
||||
if temp_unit != "F":
|
||||
return days
|
||||
def _c_to_f(v: float | None) -> float | None:
|
||||
return round(v * 9 / 5 + 32, 1) if v is not None else None
|
||||
return [
|
||||
{**d, "temp_max": _c_to_f(d.get("temp_max")), "temp_min": _c_to_f(d.get("temp_min"))}
|
||||
for d in days
|
||||
]
|
||||
|
||||
_known = {"home", "work", "all", ""}
|
||||
if location.lower() not in _known:
|
||||
try:
|
||||
lat, lon, label = await geocode(location)
|
||||
raw = await _fetch_open_meteo(lat, lon)
|
||||
days = _apply_unit(parse_forecast(raw))[:num_days]
|
||||
return {"success": True, "data": {"locations": [{
|
||||
"location_key": "query",
|
||||
"location_label": label,
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"days": days,
|
||||
"temp_unit": temp_unit,
|
||||
"changes_since_last_fetch": [],
|
||||
}]}}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Could not get weather for '{location}': {e}"}
|
||||
|
||||
locations = await get_cached_weather(user_id)
|
||||
if location.lower() not in ("all", ""):
|
||||
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
stale_cutoff = now_utc - timedelta(hours=6)
|
||||
stale_keys = []
|
||||
for loc in locations:
|
||||
try:
|
||||
fetched = datetime.fromisoformat(loc.get("fetched_at", ""))
|
||||
except Exception:
|
||||
fetched = None
|
||||
if fetched is None or fetched < stale_cutoff:
|
||||
stale_keys.append(loc["location_key"])
|
||||
if stale_keys:
|
||||
try:
|
||||
from fabledassistant.services.settings import get_setting as _wx_get_setting
|
||||
raw_cfg = await _wx_get_setting(user_id, "briefing_config", "{}")
|
||||
cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {})
|
||||
cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {}
|
||||
for key in stale_keys:
|
||||
meta = cfg_locs.get(key) or {}
|
||||
if meta.get("lat") and meta.get("lon"):
|
||||
try:
|
||||
await refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=meta.get("label", key),
|
||||
lat=meta["lat"],
|
||||
lon=meta["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Weather refresh failed for %s", key, exc_info=True)
|
||||
locations = await get_cached_weather(user_id)
|
||||
if location.lower() not in ("all", ""):
|
||||
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
|
||||
except Exception:
|
||||
logger.debug("Weather staleness refresh failed", exc_info=True)
|
||||
|
||||
stamped_locations = []
|
||||
for loc in locations:
|
||||
days = _apply_unit(loc.get("days", []))[:num_days]
|
||||
try:
|
||||
fetched = datetime.fromisoformat(loc.get("fetched_at", ""))
|
||||
age_h = (now_utc - fetched).total_seconds() / 3600.0
|
||||
except Exception:
|
||||
age_h = None
|
||||
stamped_locations.append({
|
||||
**loc,
|
||||
"days": days,
|
||||
"temp_unit": temp_unit,
|
||||
"cache_age_hours": round(age_h, 1) if age_h is not None else None,
|
||||
"is_stale": age_h is not None and age_h > 12.0,
|
||||
})
|
||||
return {"success": True, "data": {"locations": stamped_locations}}
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Web search, research, and image tools (require SearXNG)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fabledassistant.services.tools._registry import tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_web",
|
||||
description=(
|
||||
"Quick web lookup — returns search result snippets for factual questions, current events, "
|
||||
"definitions, or version numbers. No note is saved. Use research_topic when the user wants "
|
||||
"a comprehensive written report saved as a note."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "The search query"},
|
||||
},
|
||||
required=["query"],
|
||||
requires="searxng",
|
||||
)
|
||||
async def search_web_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.research import _search_searxng
|
||||
|
||||
query = arguments.get("query", "")
|
||||
results = await _search_searxng(query)
|
||||
if not results:
|
||||
return {"success": False, "error": f"No results found for '{query}'"}
|
||||
return {
|
||||
"success": True,
|
||||
"type": "web_search",
|
||||
"data": {"query": query, "results": results, "count": len(results)},
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="research_topic",
|
||||
description=(
|
||||
"Deep web research — searches multiple sources, synthesizes findings, and saves the result "
|
||||
"as a structured note with sections and citations. Use when the user says 'research', "
|
||||
"'look into', or wants a comprehensive write-up. Takes 30–120 seconds. "
|
||||
"For a quick factual answer without saving a note, use search_web."
|
||||
),
|
||||
parameters={
|
||||
"topic": {"type": "string", "description": "The topic or question to research"},
|
||||
},
|
||||
required=["topic"],
|
||||
requires="searxng",
|
||||
)
|
||||
async def research_topic_tool(*, user_id, arguments, **_ctx):
|
||||
# Research is always intercepted in generation_task.py (round 0) before execute_tool.
|
||||
# Reaching here indicates a code path regression.
|
||||
topic = arguments.get("topic", "")
|
||||
logger.error(
|
||||
"research_topic reached execute_tool — should have been intercepted upstream "
|
||||
"in generation_task.py. topic=%r",
|
||||
topic,
|
||||
)
|
||||
return {"success": False, "error": "Research could not be started. Please try again."}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_images",
|
||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use search_web for those.",
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "The image search query"},
|
||||
},
|
||||
required=["query"],
|
||||
requires="searxng",
|
||||
)
|
||||
async def search_images_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.images import fetch_and_store_image
|
||||
from fabledassistant.services.research import _search_searxng_images
|
||||
|
||||
query = arguments.get("query", "")
|
||||
if not query:
|
||||
return {"success": False, "error": "query is required"}
|
||||
raw_results = await _search_searxng_images(query)
|
||||
if not raw_results:
|
||||
return {"success": False, "error": f"No images found for '{query}'"}
|
||||
images = []
|
||||
for r in raw_results[:2]:
|
||||
img_url = r.get("img_src", "")
|
||||
if not img_url:
|
||||
continue
|
||||
record = await fetch_and_store_image(
|
||||
url=img_url,
|
||||
title=r.get("title"),
|
||||
source_domain=r.get("source_domain"),
|
||||
)
|
||||
if record:
|
||||
title = record.title or query
|
||||
page_url = r.get("page_url", "")
|
||||
source_domain = r.get("source_domain", "")
|
||||
images.append({
|
||||
"embed": f"",
|
||||
"citation": f"*Source: [{source_domain or 'image'}]({page_url})*",
|
||||
"page_url": page_url,
|
||||
"title": title,
|
||||
})
|
||||
if not images:
|
||||
return {"success": False, "error": f"Could not retrieve images for '{query}'"}
|
||||
return {
|
||||
"success": True,
|
||||
"type": "image_search",
|
||||
"data": {
|
||||
"query": query,
|
||||
"images": images,
|
||||
"instructions": (
|
||||
"Embed each image in your response by writing the 'embed' field verbatim, "
|
||||
"then the 'citation' field on the next line. Do not paraphrase or list URLs."
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""User-timezone helpers.
|
||||
|
||||
All datetimes in the DB are stored as UTC. The helpers here bridge between
|
||||
that UTC storage and the user's configured local timezone (IANA string in
|
||||
the ``user_timezone`` setting). Use these anywhere the model or UI talks
|
||||
in terms of "today", "tomorrow", or a bare calendar date — never
|
||||
``date.today()`` or ``datetime.now(timezone.utc)`` inside a per-user flow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
# Briefing day boundary — kept in sync with the compilation slot in
|
||||
# ``briefing_scheduler.SLOTS``. The briefing day flips at this local hour
|
||||
# (not midnight) so the 00:00–04:00 local window still shows yesterday's
|
||||
# briefing until the 4am compilation generates the new one.
|
||||
BRIEFING_DAY_START_HOUR = 4
|
||||
|
||||
|
||||
async def get_user_tz(user_id: int) -> ZoneInfo:
|
||||
"""Return the user's IANA ``ZoneInfo``, falling back to UTC."""
|
||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
return ZoneInfo(tz_str)
|
||||
except (ZoneInfoNotFoundError, KeyError):
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
async def user_today(user_id: int) -> date:
|
||||
"""Return today's calendar date in the user's local timezone."""
|
||||
tz = await get_user_tz(user_id)
|
||||
return datetime.now(tz).date()
|
||||
|
||||
|
||||
async def user_briefing_date(user_id: int) -> date:
|
||||
"""Return the current "briefing day" in the user's local timezone.
|
||||
|
||||
The briefing day flips at ``BRIEFING_DAY_START_HOUR`` (4am local),
|
||||
aligned with the compilation slot that generates the day's briefing.
|
||||
Between 00:00 and 04:00 local this still returns *yesterday*, so the
|
||||
UI keeps showing the in-progress briefing until the new one is built.
|
||||
"""
|
||||
tz = await get_user_tz(user_id)
|
||||
return (datetime.now(tz) - timedelta(hours=BRIEFING_DAY_START_HOUR)).date()
|
||||
@@ -114,7 +114,7 @@ async def _consolidate_observations(user_id: int) -> str:
|
||||
learned_summary paragraph. Prunes raw entries older than 30 days afterwards.
|
||||
"""
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.briefing_pipeline import _llm_synthesise
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -145,7 +145,17 @@ async def _consolidate_observations(user_id: int) -> str:
|
||||
user_prompt += f"New observations:\n{obs_text}"
|
||||
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
new_summary = await _llm_synthesise(system, user_prompt, model)
|
||||
try:
|
||||
new_summary = (await generate_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
model,
|
||||
)).strip()
|
||||
except Exception:
|
||||
logger.warning("Observation consolidation failed for user %d", user_id, exc_info=True)
|
||||
new_summary = ""
|
||||
|
||||
if new_summary:
|
||||
cutoff = (date.today() - timedelta(days=30)).isoformat()
|
||||
|
||||
@@ -184,6 +184,80 @@ async def geocode(query: str) -> tuple[float, float, str]:
|
||||
return float(r["lat"]), float(r["lon"]), r.get("display_name", query)
|
||||
|
||||
|
||||
async def fetch_current_conditions(lat: float, lon: float) -> dict | None:
|
||||
"""Fetch current temperature + conditions + next 3 hours precipitation.
|
||||
|
||||
Lightweight call — no daily forecast, no caching needed.
|
||||
Returns dict with: temperature, windspeed, weathercode, description,
|
||||
and precip_next_3h (list of hourly precip probabilities).
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(OPEN_METEO_URL, params={
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"current_weather": "true",
|
||||
"hourly": "precipitation_probability",
|
||||
"forecast_days": 1,
|
||||
"timezone": "auto",
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
current = data.get("current_weather", {})
|
||||
hourly = data.get("hourly", {})
|
||||
hourly_times = hourly.get("time", [])
|
||||
hourly_precip = hourly.get("precipitation_probability", [])
|
||||
|
||||
# Find the current hour index and get next 3 hours of precip
|
||||
current_time = current.get("time", "")
|
||||
precip_next_3h = []
|
||||
try:
|
||||
idx = next(i for i, t in enumerate(hourly_times) if t >= current_time)
|
||||
precip_next_3h = hourly_precip[idx:idx + 3]
|
||||
except (StopIteration, IndexError):
|
||||
pass
|
||||
|
||||
code = current.get("weathercode", 0)
|
||||
return {
|
||||
"temperature": current.get("temperature"),
|
||||
"windspeed": current.get("windspeed"),
|
||||
"weathercode": code,
|
||||
"description": describe_weathercode(code),
|
||||
"time": current_time,
|
||||
"precip_next_3h": precip_next_3h,
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
|
||||
"""Fetch today's hourly precipitation probabilities.
|
||||
|
||||
Returns a dict of ISO hour string → probability percentage, e.g.:
|
||||
{"2026-04-08T14:00": 65, "2026-04-08T15:00": 80, ...}
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(OPEN_METEO_URL, params={
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"hourly": "precipitation_probability",
|
||||
"forecast_days": 1,
|
||||
"timezone": "auto",
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
hourly = data.get("hourly", {})
|
||||
times = hourly.get("time", [])
|
||||
probs = hourly.get("precipitation_probability", [])
|
||||
return {t: p for t, p in zip(times, probs) if p is not None}
|
||||
except Exception:
|
||||
logger.debug("Failed to fetch hourly precip for %s, %s", lat, lon, exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
|
||||
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
|
||||
@@ -12,52 +12,6 @@ async def test_get_or_create_profile_note_returns_body():
|
||||
assert body == ""
|
||||
|
||||
|
||||
def test_format_task_for_briefing():
|
||||
"""format_task() should return a compact single-line string."""
|
||||
from fabledassistant.services.briefing_pipeline import format_task
|
||||
task = {"title": "Write design doc", "status": "in_progress", "due_date": "2026-03-12", "priority": "high"}
|
||||
result = format_task(task)
|
||||
assert "Write design doc" in result
|
||||
assert "in_progress" in result or "In Progress" in result
|
||||
|
||||
|
||||
|
||||
def test_compute_task_snapshot_hash():
|
||||
"""compute_task_hash should return a stable SHA-256 hex string."""
|
||||
from fabledassistant.services.briefing_pipeline import compute_task_hash
|
||||
|
||||
task = {"status": "todo", "priority": "high", "due_date": "2026-03-25", "title": "Write spec"}
|
||||
h = compute_task_hash(task)
|
||||
assert len(h) == 64 # SHA-256 hex
|
||||
assert h == compute_task_hash(task)
|
||||
assert h != compute_task_hash({**task, "status": "done"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_changed_tasks_all_new():
|
||||
"""split_changed_tasks should return all tasks as changed when no snapshot exists."""
|
||||
from fabledassistant.services.briefing_pipeline import split_changed_tasks
|
||||
|
||||
tasks = [
|
||||
{"task_id": 1, "title": "A", "status": "todo", "priority": "none", "due_date": None},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.briefing_pipeline.async_session"
|
||||
) as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session.execute = AsyncMock(return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
))
|
||||
mock_cls.return_value = mock_session
|
||||
|
||||
changed, unchanged_count = await split_changed_tasks(user_id=1, tasks=tasks)
|
||||
assert len(changed) == 1
|
||||
assert unchanged_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_message_accepts_metadata():
|
||||
"""post_message should accept an optional metadata dict and store it."""
|
||||
|
||||
@@ -84,7 +84,7 @@ async def test_run_slot_morning_runs_on_work_day():
|
||||
patch("fabledassistant.services.user_profile.get_profile", new_callable=AsyncMock, return_value=fake_profile), \
|
||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="UTC"), \
|
||||
patch("fabledassistant.services.briefing_conversations.get_or_create_today_conversation", new_callable=AsyncMock) as mock_conv, \
|
||||
patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock, return_value="") as mock_inject, \
|
||||
patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock, return_value=("", {})) as mock_inject, \
|
||||
patch("fabledassistant.services.briefing_conversations.post_message", new_callable=AsyncMock), \
|
||||
patch("fabledassistant.services.push.send_push_notification", new_callable=AsyncMock):
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Regression tests: calendar tool respects the user's configured timezone."""
|
||||
|
||||
from datetime import timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_all_day_bare_date_interprets_local_midnight():
|
||||
"""Bare date like '2026-09-30' for a NY user = 2026-09-30 00:00 NY,
|
||||
stored as 2026-09-30 04:00 UTC — NOT 2026-09-30 00:00 UTC (which would
|
||||
be 2026-09-29 19:00 NY and land on the wrong day)."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1, **{k: v for k, v in kwargs.items() if not callable(v)}}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Birthday", "start": "2026-09-30"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
start_dt = captured["start_dt"]
|
||||
assert start_dt.tzinfo is not None
|
||||
# Converted to UTC: 00:00 NY (EDT, UTC-4) == 04:00 UTC on the same day
|
||||
utc = start_dt.astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day) == (2026, 9, 30)
|
||||
assert utc.hour == 4 # EDT offset; would be 5 in EST
|
||||
assert captured["all_day"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_naive_datetime_is_user_local():
|
||||
"""'2026-03-15T14:30' for a NY user means 2:30pm NY, not 2:30pm UTC."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Meeting", "start": "2026-03-15T14:30"},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 14:30 NY on 2026-03-15 (after DST start) = 18:30 UTC
|
||||
assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 3, 15, 18, 30)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_explicit_offset_is_respected():
|
||||
"""Model-supplied timezone offsets must not be overridden by user TZ."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Meeting", "start": "2026-03-15T14:30+00:00"},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.hour, utc.minute) == (14, 30)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_bare_date_range_covers_local_day():
|
||||
"""date_from/date_to as bare dates should cover the full LOCAL day."""
|
||||
from fabledassistant.services.tools.calendar import list_events_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_list_events(*, user_id, date_from, date_to):
|
||||
captured["date_from"] = date_from
|
||||
captured["date_to"] = date_to
|
||||
return []
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_list_events",
|
||||
side_effect=fake_list_events,
|
||||
):
|
||||
await list_events_tool(
|
||||
user_id=1,
|
||||
arguments={"date_from": "2026-09-30", "date_to": "2026-09-30"},
|
||||
)
|
||||
|
||||
df = captured["date_from"].astimezone(timezone.utc)
|
||||
dt = captured["date_to"].astimezone(timezone.utc)
|
||||
# 2026-09-30 00:00 NY (EDT) = 04:00 UTC same day
|
||||
assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4)
|
||||
# 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day
|
||||
assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3)
|
||||
@@ -127,7 +127,7 @@ async def test_update_event_fires_caldav_push():
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_calendar_always_available():
|
||||
"""Calendar tools must appear in get_tools_for_user even without CalDAV."""
|
||||
with patch("fabledassistant.services.tools.is_caldav_configured", new_callable=AsyncMock) as mock_configured:
|
||||
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock) as mock_configured:
|
||||
mock_configured.return_value = False
|
||||
from fabledassistant.services.tools import get_tools_for_user
|
||||
tools = await get_tools_for_user(user_id=1)
|
||||
|
||||
@@ -34,8 +34,8 @@ async def test_generate_outline_returns_empty_on_parse_failure():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_returns_empty_when_fewer_than_3_sections():
|
||||
"""_generate_outline returns [] when model returns fewer than 3 valid sections."""
|
||||
async def test_generate_outline_returns_empty_when_fewer_than_2_sections():
|
||||
"""_generate_outline returns [] when model returns fewer than 2 valid sections."""
|
||||
from fabledassistant.services.research import _generate_outline
|
||||
|
||||
outline_json = json.dumps([{"title": "Only One", "focus": "Focus"}])
|
||||
@@ -106,7 +106,7 @@ async def test_synthesize_section_strips_whitespace():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_creates_section_notes_and_index():
|
||||
"""run_research_pipeline creates N section notes + 1 index note, returns index."""
|
||||
"""run_research_pipeline creates N section notes + 1 index note with links, returns index."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
outline = [
|
||||
@@ -117,23 +117,31 @@ async def test_pipeline_creates_section_notes_and_index():
|
||||
|
||||
note_id_counter = iter(range(10, 20))
|
||||
|
||||
def _make_note(user_id, title, body, tags, project_id=None):
|
||||
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
|
||||
n = MagicMock()
|
||||
n.id = next(note_id_counter)
|
||||
n.title = title
|
||||
return n
|
||||
|
||||
mock_update = AsyncMock()
|
||||
|
||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline), \
|
||||
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
|
||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note):
|
||||
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Executive summary text."), \
|
||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
|
||||
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock) as mock_update:
|
||||
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
result = await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||
|
||||
assert result.title == "Research: test topic"
|
||||
# Index note body should be updated with section links
|
||||
mock_update.assert_called_once()
|
||||
updated_body = mock_update.call_args.kwargs.get("body", "")
|
||||
assert "/notes/" in updated_body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for the `_should_think` classifier.
|
||||
|
||||
`_should_think` decides whether qwen3-class models should engage chain-of-
|
||||
thought for a given chat turn. It is the single source of truth: frontend
|
||||
callers pass `think_requested=False` by default and defer to this function,
|
||||
while explicit `think_requested=True` acts as an override for curated
|
||||
analytical entry points.
|
||||
|
||||
These tests lock in the content-based behavior so future tweaks don't
|
||||
silently regress the short / long / keyword boundaries.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fabledassistant.services.generation_task import (
|
||||
_LONG_MESSAGE_CHARS,
|
||||
_SHORT_MESSAGE_CHARS,
|
||||
_should_think,
|
||||
)
|
||||
|
||||
|
||||
class TestExplicitOverride:
|
||||
def test_override_forces_on_for_empty(self):
|
||||
assert _should_think("", think_requested=True) is True
|
||||
|
||||
def test_override_forces_on_for_short_greeting(self):
|
||||
assert _should_think("hi", think_requested=True) is True
|
||||
|
||||
def test_override_forces_on_for_medium_no_keyword(self):
|
||||
text = "just checking in on the status of things for the week"
|
||||
assert _should_think(text, think_requested=True) is True
|
||||
|
||||
|
||||
class TestEmptyAndWhitespace:
|
||||
def test_empty_string_off(self):
|
||||
assert _should_think("", think_requested=False) is False
|
||||
|
||||
def test_none_content_off(self):
|
||||
# _should_think defensively handles None content from upstream callers
|
||||
assert _should_think(None, think_requested=False) is False # type: ignore[arg-type]
|
||||
|
||||
def test_whitespace_only_off(self):
|
||||
assert _should_think(" \n\t ", think_requested=False) is False
|
||||
|
||||
|
||||
class TestShortMessages:
|
||||
def test_short_greeting_off(self):
|
||||
assert _should_think("hi", think_requested=False) is False
|
||||
|
||||
def test_short_thanks_off(self):
|
||||
assert _should_think("thanks!", think_requested=False) is False
|
||||
|
||||
def test_short_acknowledgement_off(self):
|
||||
assert _should_think("ok sounds good", think_requested=False) is False
|
||||
|
||||
def test_just_below_short_threshold_off(self):
|
||||
text = "a" * (_SHORT_MESSAGE_CHARS - 1)
|
||||
assert _should_think(text, think_requested=False) is False
|
||||
|
||||
|
||||
class TestLongMessages:
|
||||
def test_at_long_threshold_on(self):
|
||||
text = "a" * _LONG_MESSAGE_CHARS
|
||||
assert _should_think(text, think_requested=False) is True
|
||||
|
||||
def test_well_above_long_threshold_on(self):
|
||||
text = "x" * (_LONG_MESSAGE_CHARS * 3)
|
||||
assert _should_think(text, think_requested=False) is True
|
||||
|
||||
|
||||
class TestMediumMessages:
|
||||
def test_medium_no_keyword_off(self):
|
||||
# Between the short and long thresholds with no reasoning cue.
|
||||
text = "a" * ((_SHORT_MESSAGE_CHARS + _LONG_MESSAGE_CHARS) // 2)
|
||||
assert _should_think(text, think_requested=False) is False
|
||||
|
||||
|
||||
class TestKeywordTriggers:
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"why is this failing",
|
||||
"how does caching work here",
|
||||
"how do i configure this",
|
||||
"explain the retry logic",
|
||||
"analyze the latency breakdown",
|
||||
"compare gemma3 vs qwen3 for tool use",
|
||||
"please design the schema for X",
|
||||
"debug this error",
|
||||
"troubleshoot the connection issue",
|
||||
"root cause the outage",
|
||||
"review this PR",
|
||||
"critique my approach",
|
||||
"walk me through the flow",
|
||||
"step by step instructions please",
|
||||
"pros and cons of each option",
|
||||
"help me figure out what's wrong",
|
||||
"discuss this article", # covers briefing /discuss entry points
|
||||
],
|
||||
)
|
||||
def test_keyword_forces_on(self, text):
|
||||
assert _should_think(text, think_requested=False) is True
|
||||
|
||||
def test_keyword_case_insensitive(self):
|
||||
assert _should_think("WHY does this break?", think_requested=False) is True
|
||||
|
||||
def test_keyword_in_longer_sentence(self):
|
||||
text = "hey quick one — can you explain what caching does for qwen3"
|
||||
assert _should_think(text, think_requested=False) is True
|
||||
|
||||
|
||||
class TestNonTriggers:
|
||||
"""Messages that look chatty and should NOT trigger thinking."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"hey",
|
||||
"yep",
|
||||
"no worries",
|
||||
"got it, thanks",
|
||||
"good morning",
|
||||
"remind me later", # no reasoning keyword, short
|
||||
],
|
||||
)
|
||||
def test_chatty_messages_off(self, text):
|
||||
assert _should_think(text, think_requested=False) is False
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for user-timezone helpers (services/tz.py)."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_tz_returns_configured_zone():
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
with patch.object(tz_mod, "get_setting", AsyncMock(return_value="America/New_York")):
|
||||
zone = await tz_mod.get_user_tz(1)
|
||||
assert zone == ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_tz_falls_back_to_utc_on_bad_input():
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
with patch.object(tz_mod, "get_setting", AsyncMock(return_value="Not/AZone")):
|
||||
zone = await tz_mod.get_user_tz(1)
|
||||
assert zone == ZoneInfo("UTC")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_briefing_date_before_4am_returns_yesterday():
|
||||
"""00:00–03:59 local still shows yesterday's briefing day."""
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
ny = ZoneInfo("America/New_York")
|
||||
# 02:00 NY on 2026-04-13 → briefing day = 2026-04-12
|
||||
fake_now = datetime(2026, 4, 13, 2, 0, tzinfo=ny)
|
||||
|
||||
class _FakeDatetime(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return fake_now
|
||||
|
||||
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
|
||||
patch.object(tz_mod, "datetime", _FakeDatetime):
|
||||
day = await tz_mod.user_briefing_date(1)
|
||||
assert day.isoformat() == "2026-04-12"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_briefing_date_after_4am_returns_today():
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
ny = ZoneInfo("America/New_York")
|
||||
fake_now = datetime(2026, 4, 13, 4, 30, tzinfo=ny)
|
||||
|
||||
class _FakeDatetime(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return fake_now
|
||||
|
||||
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
|
||||
patch.object(tz_mod, "datetime", _FakeDatetime):
|
||||
day = await tz_mod.user_briefing_date(1)
|
||||
assert day.isoformat() == "2026-04-13"
|
||||
Reference in New Issue
Block a user