diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 557f119..25b6eab 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -121,6 +121,14 @@ jobs: echo "value=$TAGS" >> $GITHUB_OUTPUT echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT + - name: Free disk space + run: | + # Remove all unused images (including old :SHA tags) and containers. + docker system prune -af || true + # Keep the local BuildKit cache bounded so pip mount cache survives + # but stale intermediate layers don't accumulate indefinitely. + docker builder prune --keep-storage 5g -f || true + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -136,5 +144,6 @@ jobs: with: context: . push: true + provenance: false tags: ${{ steps.tags.outputs.value }} build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }} diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid new file mode 100644 index 0000000..937c971 --- /dev/null +++ b/.remember/tmp/save-session.pid @@ -0,0 +1 @@ +2298268 diff --git a/Dockerfile b/Dockerfile index c5d5342..931d8cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # Stage 1: Build Vue frontend FROM node:22-alpine AS build-frontend WORKDIR /build @@ -12,14 +13,22 @@ WORKDIR /app COPY pyproject.toml . COPY src/ src/ -RUN pip install --no-cache-dir . +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install . +# Voice dependencies (faster-whisper, Kokoro TTS, soundfile) — activated at runtime via VOICE_ENABLED +# Install CPU-only torch first so pip doesn't pull full CUDA wheels (~2 GB) for kokoro/transformers. +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install torch --index-url https://download.pytorch.org/whl/cpu \ + && pip install faster-whisper kokoro soundfile \ + && python -m spacy download en_core_web_sm # Build the fable-mcp wheel so it can be served for download COPY fable-mcp/ fable-mcp/ -RUN pip install --no-cache-dir build hatchling \ +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install build hatchling \ && python -m build --wheel ./fable-mcp --outdir /app/dist/ \ && pip uninstall -y build \ - && rm -rf fable-mcp/ /root/.cache/pip + && rm -rf fable-mcp/ COPY --from=build-frontend /build/dist/ src/fabledassistant/static/ COPY alembic.ini . diff --git a/alembic/versions/0034_add_user_profiles.py b/alembic/versions/0034_add_user_profiles.py new file mode 100644 index 0000000..8de3835 --- /dev/null +++ b/alembic/versions/0034_add_user_profiles.py @@ -0,0 +1,53 @@ +"""Add user_profiles table for structured per-user preferences.""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import ARRAY, JSONB + +revision = "0034" +down_revision = "0033" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "user_profiles", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ), + sa.Column("display_name", sa.Text(), nullable=True), + sa.Column("job_title", sa.Text(), nullable=True), + sa.Column("industry", sa.Text(), nullable=True), + sa.Column("expertise_level", sa.Text(), nullable=True), + sa.Column("response_style", sa.Text(), nullable=True), + sa.Column("tone", sa.Text(), nullable=True), + sa.Column("interests", ARRAY(sa.Text()), nullable=True), + sa.Column("work_schedule", JSONB(), nullable=True), + sa.Column("learned_summary", sa.Text(), nullable=True), + sa.Column("observations_raw", JSONB(), nullable=True), + sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_user_profiles_user_id", table_name="user_profiles") + op.drop_table("user_profiles") diff --git a/alembic/versions/0035_add_rss_item_embeddings.py b/alembic/versions/0035_add_rss_item_embeddings.py new file mode 100644 index 0000000..614ead0 --- /dev/null +++ b/alembic/versions/0035_add_rss_item_embeddings.py @@ -0,0 +1,28 @@ +"""Add rss_item_embeddings table for semantic news search.""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0035" +down_revision = "0034" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "rss_item_embeddings", + sa.Column("rss_item_id", sa.Integer(), sa.ForeignKey("rss_items.id", ondelete="CASCADE"), primary_key=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("embedding", JSONB(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_rss_item_embeddings_user_id", "rss_item_embeddings", ["user_id"]) + op.create_index("ix_rss_item_embeddings_rss_item_id", "rss_item_embeddings", ["rss_item_id"]) + + +def downgrade() -> None: + op.drop_index("ix_rss_item_embeddings_rss_item_id", table_name="rss_item_embeddings") + op.drop_index("ix_rss_item_embeddings_user_id", table_name="rss_item_embeddings") + op.drop_table("rss_item_embeddings") diff --git a/alembic/versions/0036_add_note_type_and_metadata.py b/alembic/versions/0036_add_note_type_and_metadata.py new file mode 100644 index 0000000..8ae48d6 --- /dev/null +++ b/alembic/versions/0036_add_note_type_and_metadata.py @@ -0,0 +1,28 @@ +"""Add note_type and metadata columns to notes table for entity types.""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0036" +down_revision = "0035" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "notes", + sa.Column("note_type", sa.Text(), nullable=False, server_default="note"), + ) + op.add_column( + "notes", + sa.Column("metadata", JSONB(), nullable=True), + ) + op.create_index("ix_notes_note_type", "notes", ["note_type"]) + + +def downgrade() -> None: + op.drop_index("ix_notes_note_type", table_name="notes") + op.drop_column("notes", "metadata") + op.drop_column("notes", "note_type") diff --git a/alembic/versions/0037_add_event_reminders.py b/alembic/versions/0037_add_event_reminders.py new file mode 100644 index 0000000..6a33a3d --- /dev/null +++ b/alembic/versions/0037_add_event_reminders.py @@ -0,0 +1,29 @@ +"""Add reminder_minutes and reminder_sent_at to events. + +Revision ID: 0037 +Revises: 0036 +Create Date: 2026-04-04 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0037" +down_revision = "0036" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("events", sa.Column("reminder_minutes", sa.Integer(), nullable=True)) + op.add_column( + "events", + sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("events", "reminder_sent_at") + op.drop_column("events", "reminder_minutes") diff --git a/docker-compose.yml b/docker-compose.yml index 5cf9bb6..7be1606 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: environment: DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}" OLLAMA_URL: "http://ollama:11434" - OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}" + OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:8B}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" # Uncomment and set to enable web research and image search via SearXNG: # SEARXNG_URL: "http://searxng:8080" diff --git a/docs/2026-03-29-voice-s2s-design.md b/docs/2026-03-29-voice-s2s-design.md new file mode 100644 index 0000000..3e0e88d --- /dev/null +++ b/docs/2026-03-29-voice-s2s-design.md @@ -0,0 +1,200 @@ +# Speech-to-Speech (S2S) Design Spec + +**Branch:** `feature/voice-s2s` +**Date:** 2026-03-29 +**Status:** Approved for implementation + +--- + +## Decisions + +- **STT:** faster-whisper (in-process Python, model size configurable via `STT_MODEL` env var) +- **TTS:** Kokoro TTS (in-process Python, voice/speed/style configurable per-user) +- **Input mode:** Push-to-talk (Phase 1); VAD deferred +- **No browser STT/TTS fallbacks** — self-hosted only, data stays on-server +- **No Android app** — web only for this implementation + +--- + +## New Env Vars (`config.py`) + +| Var | Default | Description | +|---|---|---| +| `VOICE_ENABLED` | `false` | Feature flag — opt-in | +| `STT_BACKEND` | `faster-whisper` | Only supported value currently | +| `STT_MODEL` | `base.en` | `tiny.en` / `base.en` / `small.en` / `medium.en` | +| `TTS_BACKEND` | `kokoro` | Only supported value currently | + +--- + +## Per-User Settings (stored in `settings` table) + +| Key | Default | Description | +|---|---|---| +| `voice_tts_voice` | `af_heart` | Kokoro voice ID | +| `voice_tts_speed` | `1.0` | Speech rate, 0.7–1.3 | +| `voice_speech_style` | `conversational` | `conversational` / `concise` / `detailed` | + +--- + +## New Backend Files + +### `src/fabledassistant/services/stt.py` +Lazy singleton `WhisperModel` loader. Public API: +- `load_stt_model()` — called at startup via `asyncio.create_task` +- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text +- `stt_available() -> bool` + +### `src/fabledassistant/services/tts.py` +Lazy singleton `KPipeline` loader. Public API: +- `load_tts_model()` — called at startup +- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono) +- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels +- `tts_available() -> bool` + +### `src/fabledassistant/routes/voice.py` +Blueprint at `/api/voice`, all routes `@login_required`. + +| Endpoint | Method | Description | +|---|---|---| +| `/api/voice/status` | GET | STT/TTS availability; `enabled` false if `VOICE_ENABLED=false` | +| `/api/voice/voices` | GET | List available Kokoro voices | +| `/api/voice/transcribe` | POST | multipart `audio` field → `{"transcript": "...", "duration_ms": 123}` | +| `/api/voice/synthesise` | POST | `{"text", "voice", "speed"}` → WAV bytes | + +--- + +## Modified Backend Files + +### `src/fabledassistant/app.py` +- Register `voice_bp` blueprint +- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED` + +### `src/fabledassistant/config.py` +- Add 4 new env var attributes +- Add validation in `validate()` + +### `src/fabledassistant/services/llm.py` +- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()` +- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."* +- Append style modifier based on `voice_speech_style` + +### `src/fabledassistant/services/generation_task.py` +- Add `voice_mode: bool = False` to `run_generation()` +- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()` + +### `src/fabledassistant/routes/chat.py` +- Allow `"voice"` in `conversation_type` whitelist + +### `src/fabledassistant/services/chat.py` +- Exclude `conversation_type == "voice"` from auto-cleanup retention + +--- + +## New Frontend Files + +### `frontend/src/composables/useVoiceRecorder.ts` +Wraps `MediaRecorder`. Exports: `recording`, `error`, `isSupported`, `startRecording()`, `stopRecording() -> Promise`. + +### `frontend/src/composables/useVoiceAudio.ts` +Wraps `AudioContext`. Exports: `playing`, `isSupported`, `play(blob)`, `stop()`. + +### `frontend/src/components/VoiceOverlay.vue` +Floating PTT button (fixed bottom-right). Creates/reuses a `"voice"` conversation. Full flow: record → transcribe → send → stream → synthesise → play. Space bar hotkey (from `App.vue`). Mounted globally in `App.vue`. + +--- + +## Modified Frontend Files + +### `frontend/src/api/client.ts` +Add: `transcribeAudio(blob)`, `synthesiseSpeech(text, voice?, speed?)`, `getVoiceStatus()`, `getVoiceList()` + +### `frontend/src/views/BriefingView.vue` +- "Listen" button: reads latest assistant message aloud via TTS +- Mic button in input bar: PTT → transcribe → auto-fill input → send +- Auto-TTS on assistant response when in listen mode + +### `frontend/src/views/SettingsView.vue` +- New "Voice" tab: voice dropdown, speed slider, speech style radio +- Loads from `/api/settings`, saves via `PUT /api/settings` + +### `frontend/src/App.vue` +- Mount `` +- Space bar → `"voice:ptt-toggle"` custom event + +--- + +## Audio Format + +| Direction | Format | Rationale | +|---|---|---| +| Browser → Server | WebM/Opus | Native `MediaRecorder` output; no re-encoding | +| Server → Browser | WAV (24kHz, 16-bit mono) | Kokoro native; no re-encoding; `decodeAudioData` compatible | + +--- + +## Dependencies to Add (`pyproject.toml`) + +```toml +[project.optional-dependencies] +voice = [ + "faster-whisper>=1.0", + "kokoro>=0.9", + "soundfile>=0.12", +] +``` + +Install unconditionally in Docker (activated by `VOICE_ENABLED` at runtime): +```dockerfile +RUN pip install faster-whisper kokoro soundfile +``` + +--- + +## Database Migration + +No schema changes required. `conversation_type` is unconstrained TEXT. Voice settings use existing key-value `settings` table. Optional no-op migration `0034_voice_conversation_type.py` for audit trail. + +--- + +## Implementation Phases + +### Phase 1 — Backend services + routes +1. Add env vars to `config.py` +2. Create `services/stt.py` (faster-whisper) +3. Create `services/tts.py` (Kokoro) +4. Create `routes/voice.py` (4 endpoints) +5. Wire model loading into `app.py` startup +6. Add `voice_mode` to `build_context()` + `run_generation()` +7. Allow `"voice"` conversation type in chat route + cleanup exclusion + +### Phase 2 — BriefingView listen + voice follow-up +1. Create `useVoiceRecorder.ts` +2. Create `useVoiceAudio.ts` +3. Add voice API functions to `client.ts` +4. Add "Listen" button + mic button to `BriefingView.vue` + +### Phase 3 — VoiceOverlay for general voice chat +1. Create `VoiceOverlay.vue` +2. Mount in `App.vue` + Space bar hotkey + +### Phase 4 — Settings UI +1. Add "Voice" tab to `SettingsView.vue` + +--- + +## Kokoro Voice Reference + +| ID | Character | +|---|---| +| `af_heart` | American female, warm (recommended default) | +| `af_bella` | American female, expressive | +| `af_nicole` | American female, breathy/intimate | +| `af_sarah` | American female, clear | +| `af_sky` | American female, bright | +| `am_adam` | American male, neutral | +| `am_michael` | American male, deeper | +| `bf_emma` | British female | +| `bf_isabella` | British female, formal | +| `bm_george` | British male | +| `bm_lewis` | British male, casual | diff --git a/docs/superpowers/plans/2026-04-03-chatpanel-unification.md b/docs/superpowers/plans/2026-04-03-chatpanel-unification.md new file mode 100644 index 0000000..cac0f63 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-chatpanel-unification.md @@ -0,0 +1,2141 @@ +# ChatPanel Unification 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 four divergent chat surfaces with a single `ChatPanel` component so every fix and feature applies everywhere automatically. + +**Architecture:** Extract `ChatInputBar.vue` (input + note picker + PTT) and `ChatStreamingBubble.vue` (streaming state display), then build `ChatPanel.vue` with `variant="full"` and `variant="widget"`, and migrate each view to use it. ChatPanel reads from `useChatStore` directly — no prop-drilling of messages or streaming state. + +**Tech Stack:** Vue 3 Composition API, TypeScript, Pinia (useChatStore), existing composables (useStreamingTts, useVoiceRecorder, useVoiceAudio, useListenMode) + +**Important context:** +- `ChatPanel.vue` already exists but is orphaned (zero usages). Overwrite it completely. +- `InlineAssistPanel.vue` and `HistoryPanel.vue` are also orphaned — ignore them. +- `chatStore.sendMessage(content, contextNoteId?, includeNoteIds?, think?, contextNoteTitle?, excludeNoteIds?, ragProjectId?, workspaceProjectId?)` — always pass `think: true` +- The store adds an optimistic user message immediately in `sendMessage`, so no manual optimistic push needed. +- TypeScript check command: `cd frontend && npm run typecheck` (or `make typecheck` from repo root) + +--- + +## File Map + +| File | Action | Purpose | +|---|---|---| +| `frontend/src/components/ChatInputBar.vue` | **Create** | Unified input bar: textarea, note picker, PTT, send/abort | +| `frontend/src/components/ChatStreamingBubble.vue` | **Create** | Streaming state: tool calls, think block, content, typing indicator | +| `frontend/src/components/ChatPanel.vue` | **Overwrite** | Full+widget chat panel, owns TTS/listen/scroll | +| `frontend/src/views/ChatView.vue` | **Modify** | Replace chat-main section with `` | +| `frontend/src/views/BriefingView.vue` | **Modify** | Replace briefing-center chat with `` | +| `frontend/src/views/WorkspaceView.vue` | **Modify** | Replace inline chat with `` | +| `frontend/src/views/HomeView.vue` | **Modify** | Replace DashboardChatInput + response section with `` | +| `frontend/src/components/DashboardChatInput.vue` | **Delete** | Replaced by ChatPanel widget | + +--- + +### Task 1: Create `ChatInputBar.vue` + +**Files:** +- Create: `frontend/src/components/ChatInputBar.vue` + +- [ ] **Step 1: Create the file** + +```vue + + + + + +``` + +- [ ] **Step 2: TypeScript check** + +```bash +cd /path/to/project/frontend && npm run typecheck 2>&1 | tail -20 +``` +Expected: no errors related to `ChatInputBar.vue`. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/ChatInputBar.vue +git commit -m "feat: add ChatInputBar unified input bar component" +``` + +--- + +### Task 2: Create `ChatStreamingBubble.vue` + +**Files:** +- Create: `frontend/src/components/ChatStreamingBubble.vue` + +- [ ] **Step 1: Create the file** + +This extracts the identical streaming bubble HTML that currently lives in both `ChatView.vue` and `WorkspaceView.vue`. + +```vue + + + + + +``` + +- [ ] **Step 2: TypeScript check** + +```bash +cd /path/to/project/frontend && npm run typecheck 2>&1 | tail -20 +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/ChatStreamingBubble.vue +git commit -m "feat: add ChatStreamingBubble extracted streaming state component" +``` + +--- + +### Task 3: Rewrite `ChatPanel.vue` — full variant + +**Files:** +- Overwrite: `frontend/src/components/ChatPanel.vue` + +The existing `ChatPanel.vue` is orphaned (zero usages) — overwrite it entirely. + +**Context for this component:** +- `chatStore.sendMessage(content, contextNoteId?, includeNoteIds?, think?, contextNoteTitle?, excludeNoteIds?, ragProjectId?, workspaceProjectId?)` +- Always pass `think: true` +- For workspace: both `ragProjectId` and `workspaceProjectId` = `props.projectId` +- For briefing: no extra args beyond content +- For full chat: pass `includedNoteIds`, `excludedNoteIds`, `store.ragProjectId` + +- [ ] **Step 1: Write the full variant implementation** + +```vue + + + + + +``` + +- [ ] **Step 2: TypeScript check** + +```bash +cd /path/to/project/frontend && npm run typecheck 2>&1 | tail -30 +``` +Expected: no errors in `ChatPanel.vue`, `ChatInputBar.vue`, or `ChatStreamingBubble.vue`. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/ChatPanel.vue +git commit -m "feat: rewrite ChatPanel with full and widget variants" +``` + +--- + +### Task 4: Migrate `ChatView.vue` + +**Files:** +- Modify: `frontend/src/views/ChatView.vue` + +ChatView keeps: sidebar (conversation list, bulk delete), route wiring, `onMounted`/`watch(convId)`, summarize button, research modal. It removes: all input bar HTML, streaming bubble HTML, scroll management, TTS/PTT/listen wiring, note context state. + +- [ ] **Step 1: Remove imports that ChatPanel now owns** + +In ` + + + + diff --git a/frontend/src/components/ChatPanel.vue b/frontend/src/components/ChatPanel.vue index dad9059..3be41ef 100644 --- a/frontend/src/components/ChatPanel.vue +++ b/frontend/src/components/ChatPanel.vue @@ -1,652 +1,712 @@ diff --git a/frontend/src/components/ChatStreamingBubble.vue b/frontend/src/components/ChatStreamingBubble.vue new file mode 100644 index 0000000..d3141f3 --- /dev/null +++ b/frontend/src/components/ChatStreamingBubble.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/components/DashboardChatInput.vue b/frontend/src/components/DashboardChatInput.vue deleted file mode 100644 index a761a74..0000000 --- a/frontend/src/components/DashboardChatInput.vue +++ /dev/null @@ -1,351 +0,0 @@ - - - - - diff --git a/frontend/src/components/EventSlideOver.vue b/frontend/src/components/EventSlideOver.vue index 5c5a776..0eb2c39 100644 --- a/frontend/src/components/EventSlideOver.vue +++ b/frontend/src/components/EventSlideOver.vue @@ -68,9 +68,11 @@ function resetForm() { if (props.event) { title.value = props.event.title; allDay.value = props.event.all_day; - startDate.value = dateFromIso(props.event.start_dt); + // All-day events: use UTC date string directly to avoid timezone shifting + // (UTC midnight parsed through new Date() becomes the previous day in UTC-X zones) + startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt); startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt); - endDate.value = props.event.end_dt ? dateFromIso(props.event.end_dt) : ""; + endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : ""; endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : ""; description.value = props.event.description || ""; location.value = props.event.location || ""; diff --git a/frontend/src/components/ToolCallCard.vue b/frontend/src/components/ToolCallCard.vue index f1723c8..54c0fdb 100644 --- a/frontend/src/components/ToolCallCard.vue +++ b/frontend/src/components/ToolCallCard.vue @@ -270,8 +270,11 @@ async function openEventSlideOver(id: number | undefined) { } } -function closeEventSlideOver() { +function closeEventSlideOver(changed = false) { eventSlideOverOpen.value = false; + if (changed) { + document.dispatchEvent(new Event("fable:calendar-changed")); + } } @@ -522,9 +525,9 @@ function closeEventSlideOver() { :event="eventSlideOverEntry" initial-date="" @close="closeEventSlideOver" - @created="closeEventSlideOver" - @updated="closeEventSlideOver" - @deleted="closeEventSlideOver" + @created="() => closeEventSlideOver(true)" + @updated="() => closeEventSlideOver(true)" + @deleted="() => closeEventSlideOver(true)" /> diff --git a/frontend/src/components/VoiceOverlay.vue b/frontend/src/components/VoiceOverlay.vue new file mode 100644 index 0000000..470a648 --- /dev/null +++ b/frontend/src/components/VoiceOverlay.vue @@ -0,0 +1,538 @@ + + + + + diff --git a/frontend/src/components/WeatherCard.vue b/frontend/src/components/WeatherCard.vue index e83f604..c25cebd 100644 --- a/frontend/src/components/WeatherCard.vue +++ b/frontend/src/components/WeatherCard.vue @@ -6,6 +6,9 @@ interface ForecastDay { condition: string high: number low: number + precip_probability: number | null + precip_mm: number | null + windspeed_max: number } interface WeatherData { @@ -17,6 +20,7 @@ interface WeatherData { today_low: number | null yesterday_high: number | null yesterday_low: number | null + wind_unit?: string forecast: ForecastDay[] } @@ -25,6 +29,23 @@ const props = defineProps<{ tempUnit?: string }>() +function weatherIcon(condition: string): string { + const c = condition.toLowerCase() + if (c.includes('thunderstorm')) return '⛈️' + if (c.includes('hail')) return '🌨️' + if (c.includes('snow showers')) return '🌨️' + if (c.includes('snow')) return '❄️' + if (c.includes('rain showers: violent')) return '⛈️' + if (c.includes('rain showers')) return '🌦️' + if (c.includes('drizzle') || c.includes('rain')) return '🌧️' + if (c.includes('fog')) return '🌫️' + if (c.includes('overcast')) return '☁️' + if (c.includes('partly cloudy')) return '⛅' + if (c.includes('mainly clear')) return '🌤️' + if (c.includes('clear')) return '☀️' + return '🌡️' +} + const unit = computed(() => props.tempUnit ?? 'C') const tempDelta = computed(() => { @@ -56,6 +77,7 @@ const fetchedAtLabel = computed(() => { as of {{ fetchedAtLabel }}
+ {{ weatherIcon(weather.condition) }} {{ weather.current_temp }}°{{ unit }} {{ weather.condition }}
@@ -66,8 +88,17 @@ const fetchedAtLabel = computed(() => {
{{ day.day }} + {{ weatherIcon(day.condition) }} {{ day.condition }} {{ day.high }}° / {{ day.low }}° + + 💧 {{ day.precip_probability }}% + + + 💧 {{ day.precip_mm.toFixed(1) }}mm + + 💧 — + 💨 {{ day.windspeed_max }} {{ weather.wind_unit ?? 'km/h' }}
@@ -110,6 +141,11 @@ const fetchedAtLabel = computed(() => { margin-bottom: 0.5rem; } +.weather-icon { + font-size: 2rem; + line-height: 1; +} + .weather-temp { font-size: 2rem; font-weight: 700; @@ -153,16 +189,34 @@ const fetchedAtLabel = computed(() => { font-weight: 600; } +.forecast-icon { + font-size: 1.2rem; + line-height: 1; +} + .forecast-condition { + font-size: 0.7rem; color: var(--color-text-muted); - font-size: 0.75rem; text-align: center; + line-height: 1.2; + word-break: break-word; } .forecast-temps { white-space: nowrap; } +.forecast-precip, +.forecast-wind { + font-size: 0.72rem; + white-space: nowrap; + color: var(--color-text-muted); +} + +.forecast-precip--dry { + opacity: 0.35; +} + .weather-unavailable { color: var(--color-text-muted); font-style: italic; diff --git a/frontend/src/components/WorkspaceNoteEditor.vue b/frontend/src/components/WorkspaceNoteEditor.vue index 4c02c2f..a5bb3f6 100644 --- a/frontend/src/components/WorkspaceNoteEditor.vue +++ b/frontend/src/components/WorkspaceNoteEditor.vue @@ -293,6 +293,8 @@ onMounted(async () => { }); onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); }); + +defineExpose({ reload: loadProjectNotes });