Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8945536c4 | |||
| f30e90ef8d | |||
| 00f82f8cba | |||
| f953a36b86 | |||
| 4bc7f9eaac | |||
| 670de547ad | |||
| a684d84edf | |||
| 34729cf1cf | |||
| dd16b39218 | |||
| 0bd362f582 | |||
| 0afa2d8771 | |||
| 93af4e6bab | |||
| 6e3de91a6f | |||
| 6e0c528126 | |||
| dd3b59ed36 | |||
| 56b687c9f4 | |||
| 6d57840dc7 | |||
| 7f37cee49f | |||
| 39e554d938 | |||
| b3cf42863a | |||
| d290bebad2 | |||
| 58e5c6bc60 | |||
| d3170e5545 | |||
| 814f44c3fb | |||
| 1d0cf4828b | |||
| 56f1c44b8e | |||
| a5e35f7c72 | |||
| 853dc810ff | |||
| ac7dde472f | |||
| 84926d4ba2 | |||
| 9b69e38aff | |||
| 1b68559bfe | |||
| edf0a9063e | |||
| f2c2117b25 | |||
| b9d0716b01 | |||
| 9af8ab8f70 | |||
| a171210224 | |||
| eb92b2a976 | |||
| be805073a7 | |||
| e4c812a603 | |||
| 8f7590d322 | |||
| 3bdadaeca8 | |||
| c8c0de3b04 | |||
| 1357046160 | |||
| 8ec91ceea7 | |||
| 0ec030cb8f | |||
| f4aca40562 | |||
| 68eee57c9b | |||
| 284dcd1c63 | |||
| 7dc5af2e88 | |||
| eeb671872a | |||
| 278927ec40 | |||
| f7d54a15c0 | |||
| 9dd4178774 | |||
| eed2f6c23a | |||
| db092b113e | |||
| b5106441dd | |||
| 94d21c4512 | |||
| d9bd16633f | |||
| c5191837fb | |||
| ed715dcc23 | |||
| 738245af5c | |||
| edfed6b5bb | |||
| 358534efbf | |||
| 7677ab4028 | |||
| 90afbec4c2 | |||
| 5495fd1500 | |||
| 1fc0004e93 | |||
| 968e536d3a | |||
| 3c38c04ad4 | |||
| 4ac26d9326 | |||
| 00abfcf4db | |||
| 3887cab66e | |||
| aeb778f35a | |||
| eda9c5ce43 | |||
| d624d38412 | |||
| 86e718dda1 | |||
| 0b1ed2afe5 | |||
| 2c446be83a | |||
| f36398f892 | |||
| 927f137aaf | |||
| 7eaf4d9dca | |||
| 89a9088b94 | |||
| c89586dcd5 | |||
| fb26507123 | |||
| 0a913045a8 | |||
| c81a499e6e | |||
| 8024706870 | |||
| 22003788f5 | |||
| 9d519054ee | |||
| b4f5a935b2 | |||
| fda6a7acc1 | |||
| 7ffd412603 | |||
| b92c6b1487 | |||
| b8cd2e5ed7 | |||
| ef55bcb560 | |||
| a6888953dc | |||
| c9065c4481 | |||
| 4792e6459b | |||
| 3bd0dc6879 | |||
| fa38978745 | |||
| 750a91898a | |||
| 888b736ecd | |||
| a473f6e039 | |||
| 07f4956550 | |||
| b4be1f0799 | |||
| 36634919cc | |||
| 8a10eb9dbd | |||
| 2422946b4f | |||
| b416fec292 | |||
| d20320b664 | |||
| c3665ddda5 | |||
| a7160772bf | |||
| 746b21fa4c | |||
| 8a54daf3c9 | |||
| 25d448f896 | |||
| 3e42992f67 | |||
| 749a60b9fd | |||
| aec7a910f0 | |||
| 9549eb85bc | |||
| 23a7ed7822 | |||
| d5771a3d5c | |||
| c95afa4558 | |||
| 8140bc022c | |||
| 0d12d01115 | |||
| d96895c276 | |||
| 145c18d8a3 | |||
| f96013a4bc | |||
| 30981a3121 | |||
| 71b8c5965c | |||
| 5924e565b1 | |||
| a0620c4949 | |||
| 95056d5be7 | |||
| 80f30b705d | |||
| 425d307180 | |||
| f2dd25737a | |||
| 882ea176b2 | |||
| baeb0b14e5 | |||
| ab397e78f3 | |||
| ea23f16bd7 | |||
| c1fcb1e287 | |||
| c31cf11767 | |||
| 71ca0ecb5c | |||
| b4b4b0d9d6 | |||
| 76c3dbc4b7 | |||
| 1460863e82 | |||
| 98b3cdb593 | |||
| c2d81e04b9 | |||
| e613485474 | |||
| 0b05b03987 | |||
| a773c11aa0 | |||
| dba41879ed | |||
| 9f3b9e45c6 | |||
| 2a8c0cfa56 | |||
| dd304bb556 | |||
| f8b7b51832 | |||
| 20e2333b63 |
@@ -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 }}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
2298268
|
||||
+11
-4
@@ -1,3 +1,4 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Stage 1: Build Vue frontend
|
||||
FROM node:22-alpine AS build-frontend
|
||||
WORKDIR /build
|
||||
@@ -12,16 +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
|
||||
RUN pip install --no-cache-dir faster-whisper kokoro soundfile
|
||||
# Install CPU-only torch first so pip doesn't pull full CUDA wheels (~2 GB) for kokoro/transformers.
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu \
|
||||
&& pip install faster-whisper kokoro soundfile \
|
||||
&& python -m spacy download en_core_web_sm
|
||||
|
||||
# Build the fable-mcp wheel so it can be served for download
|
||||
COPY fable-mcp/ fable-mcp/
|
||||
RUN pip install --no-cache-dir build hatchling \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install build hatchling \
|
||||
&& python -m build --wheel ./fable-mcp --outdir /app/dist/ \
|
||||
&& pip uninstall -y build \
|
||||
&& rm -rf fable-mcp/ /root/.cache/pip
|
||||
&& rm -rf fable-mcp/
|
||||
|
||||
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
|
||||
COPY alembic.ini .
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Add user_profiles table for structured per-user preferences."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
|
||||
revision = "0034"
|
||||
down_revision = "0033"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"user_profiles",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
),
|
||||
sa.Column("display_name", sa.Text(), nullable=True),
|
||||
sa.Column("job_title", sa.Text(), nullable=True),
|
||||
sa.Column("industry", sa.Text(), nullable=True),
|
||||
sa.Column("expertise_level", sa.Text(), nullable=True),
|
||||
sa.Column("response_style", sa.Text(), nullable=True),
|
||||
sa.Column("tone", sa.Text(), nullable=True),
|
||||
sa.Column("interests", ARRAY(sa.Text()), nullable=True),
|
||||
sa.Column("work_schedule", JSONB(), nullable=True),
|
||||
sa.Column("learned_summary", sa.Text(), nullable=True),
|
||||
sa.Column("observations_raw", JSONB(), nullable=True),
|
||||
sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_user_profiles_user_id", table_name="user_profiles")
|
||||
op.drop_table("user_profiles")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add rss_item_embeddings table for semantic news search."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0035"
|
||||
down_revision = "0034"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rss_item_embeddings",
|
||||
sa.Column("rss_item_id", sa.Integer(), sa.ForeignKey("rss_items.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("embedding", JSONB(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_rss_item_embeddings_user_id", "rss_item_embeddings", ["user_id"])
|
||||
op.create_index("ix_rss_item_embeddings_rss_item_id", "rss_item_embeddings", ["rss_item_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rss_item_embeddings_rss_item_id", table_name="rss_item_embeddings")
|
||||
op.drop_index("ix_rss_item_embeddings_user_id", table_name="rss_item_embeddings")
|
||||
op.drop_table("rss_item_embeddings")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add note_type and metadata columns to notes table for entity types."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0036"
|
||||
down_revision = "0035"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("note_type", sa.Text(), nullable=False, server_default="note"),
|
||||
)
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("metadata", JSONB(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_notes_note_type", "notes", ["note_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_notes_note_type", table_name="notes")
|
||||
op.drop_column("notes", "metadata")
|
||||
op.drop_column("notes", "note_type")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Add reminder_minutes and reminder_sent_at to events.
|
||||
|
||||
Revision ID: 0037
|
||||
Revises: 0036
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0037"
|
||||
down_revision = "0036"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("events", sa.Column("reminder_minutes", sa.Integer(), nullable=True))
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("events", "reminder_sent_at")
|
||||
op.drop_column("events", "reminder_minutes")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,594 @@
|
||||
# Streaming TTS Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Start playing TTS audio during LLM generation by splitting responses into sentences and synthesizing each sentence as it completes, rather than waiting for the full response.
|
||||
|
||||
**Architecture:** A new `useStreamingTts` composable watches `streamingContent` for sentence boundaries, fires per-sentence `synthesiseSpeech` requests concurrently, and plays audio in strict insertion order using `useVoiceAudio`. ChatView, BriefingView, and WorkspaceView all use this composable, replacing their current post-stream speak logic.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, TypeScript, `useVoiceAudio` (existing), `synthesiseSpeech` from `api/client.ts` (existing), no backend changes.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| **Create** | `frontend/src/composables/useStreamingTts.ts` | All streaming TTS logic: sentence splitting, TTS queuing, ordered playback |
|
||||
| **Modify** | `frontend/src/views/ChatView.vue` | Replace `speakLastAssistantMessage` + old watch with `useStreamingTts` |
|
||||
| **Modify** | `frontend/src/views/BriefingView.vue` | Replace `speakText` + `listenToLatest` + old watch with `useStreamingTts` |
|
||||
| **Modify** | `frontend/src/views/WorkspaceView.vue` | Add listen mode toggle button + `useStreamingTts` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create `useStreamingTts` composable
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useStreamingTts.ts`
|
||||
|
||||
- [ ] **Step 1: Create the composable**
|
||||
|
||||
Create `frontend/src/composables/useStreamingTts.ts` with the full implementation:
|
||||
|
||||
```typescript
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { synthesiseSpeech } from '@/api/client'
|
||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||||
|
||||
/** Minimum stripped character count to bother synthesizing. */
|
||||
const MIN_CHARS = 3
|
||||
|
||||
/** Matches sentence-terminal punctuation followed by whitespace or end-of-string. */
|
||||
const SENTENCE_BOUNDARY = /[.!?]+(?=\s|$)/
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
|
||||
.replace(/#{1,6}\s+/g, '')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/\n{2,}/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract completed sentences from `text` using SENTENCE_BOUNDARY.
|
||||
* Returns the sentences found and the unconsumed remainder.
|
||||
*/
|
||||
function extractSentences(text: string): { sentences: string[]; remainder: string } {
|
||||
const sentences: string[] = []
|
||||
let remaining = text
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = SENTENCE_BOUNDARY.exec(remaining)) !== null) {
|
||||
const boundary = match.index + match[0].length
|
||||
const sentence = remaining.slice(0, boundary).trim()
|
||||
if (sentence) sentences.push(sentence)
|
||||
remaining = remaining.slice(boundary)
|
||||
}
|
||||
|
||||
return { sentences, remainder: remaining }
|
||||
}
|
||||
|
||||
export interface UseStreamingTtsOptions {
|
||||
streamingContent: Ref<string> | ComputedRef<string>
|
||||
streaming: Ref<boolean> | ComputedRef<boolean>
|
||||
enabled: Ref<boolean> | ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export interface UseStreamingTtsReturn {
|
||||
/** True while any synthesis request is in-flight or audio is playing. */
|
||||
speaking: ComputedRef<boolean>
|
||||
/** Cancel all in-flight synthesis/playback and clear the queue. */
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export function useStreamingTts(options: UseStreamingTtsOptions): UseStreamingTtsReturn {
|
||||
const { streamingContent, streaming, enabled } = options
|
||||
const audio = useVoiceAudio()
|
||||
|
||||
let sentenceBuffer = ''
|
||||
let lastSeenLength = 0
|
||||
let abortId = 0
|
||||
let playQueue: Promise<void> = Promise.resolve()
|
||||
const pendingCount = ref(0)
|
||||
|
||||
const speaking = computed(() => pendingCount.value > 0 || audio.playing.value)
|
||||
|
||||
function stop(): void {
|
||||
abortId++
|
||||
sentenceBuffer = ''
|
||||
lastSeenLength = 0
|
||||
playQueue = Promise.resolve()
|
||||
audio.stop()
|
||||
pendingCount.value = 0
|
||||
}
|
||||
|
||||
async function enqueueSentence(sentence: string, myAbortId: number): Promise<void> {
|
||||
const stripped = stripMarkdown(sentence)
|
||||
if (stripped.length < MIN_CHARS) return
|
||||
|
||||
pendingCount.value++
|
||||
let blob: Blob | null = null
|
||||
try {
|
||||
blob = await synthesiseSpeech(stripped)
|
||||
} catch (e) {
|
||||
console.warn('[StreamingTTS] Synthesis failed, retrying sentence', { sentence: stripped, error: e })
|
||||
try {
|
||||
blob = await synthesiseSpeech(stripped)
|
||||
} catch (e2) {
|
||||
console.warn('[StreamingTTS] Retry also failed, skipping sentence', { sentence: stripped, error: e2 })
|
||||
}
|
||||
} finally {
|
||||
pendingCount.value--
|
||||
}
|
||||
|
||||
if (!blob) return
|
||||
|
||||
// Capture blob for the closure — TS can't narrow after async gap
|
||||
const resolvedBlob = blob
|
||||
playQueue = playQueue.then(async () => {
|
||||
if (abortId !== myAbortId) return
|
||||
await audio.play(resolvedBlob)
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchBuffer(flush: boolean): void {
|
||||
if (!enabled.value) return
|
||||
const myAbortId = abortId
|
||||
const { sentences, remainder } = extractSentences(sentenceBuffer)
|
||||
sentenceBuffer = flush ? '' : remainder
|
||||
for (const sentence of sentences) {
|
||||
enqueueSentence(sentence, myAbortId)
|
||||
}
|
||||
if (flush && remainder.trim().length >= MIN_CHARS) {
|
||||
enqueueSentence(remainder.trim(), myAbortId)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch accumulating content — extract new characters since last check
|
||||
watch(streamingContent, (newContent) => {
|
||||
if (!enabled.value) return
|
||||
const delta = newContent.slice(lastSeenLength)
|
||||
lastSeenLength = newContent.length
|
||||
sentenceBuffer += delta
|
||||
dispatchBuffer(false)
|
||||
})
|
||||
|
||||
// Watch streaming flag — stop on new message start, flush on end
|
||||
watch(streaming, (isStreaming) => {
|
||||
if (!enabled.value) return
|
||||
if (isStreaming) {
|
||||
// New message starting — cancel previous response's audio
|
||||
stop()
|
||||
} else {
|
||||
// Stream ended — flush any remaining fragment
|
||||
dispatchBuffer(true)
|
||||
lastSeenLength = 0
|
||||
}
|
||||
})
|
||||
|
||||
return { speaking, stop }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Expected: no errors mentioning `useStreamingTts.ts`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useStreamingTts.ts
|
||||
git commit -m "feat(tts): add useStreamingTts composable for sentence-level streaming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update ChatView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/ChatView.vue`
|
||||
|
||||
Current TTS code to remove (lines ~35–66):
|
||||
|
||||
```typescript
|
||||
// REMOVE these:
|
||||
const synthesising = ref(false);
|
||||
|
||||
async function speakLastAssistantMessage() { ... } // entire function
|
||||
|
||||
watch(() => store.streaming, async (streaming) => {
|
||||
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await speakLastAssistantMessage();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Also remove the `synthesiseSpeech` import from `@/api/client` (it is no longer called directly in this file).
|
||||
|
||||
- [ ] **Step 1: Add import and replace TTS logic**
|
||||
|
||||
In `frontend/src/views/ChatView.vue`:
|
||||
|
||||
1. Add to imports at the top of `<script setup>`:
|
||||
```typescript
|
||||
import { useStreamingTts } from "@/composables/useStreamingTts";
|
||||
```
|
||||
|
||||
2. Remove `synthesiseSpeech` from the `@/api/client` import line (keep other imports like `apiGet`, `transcribeAudio`).
|
||||
|
||||
3. Remove `const synthesising = ref(false);` (line ~35).
|
||||
|
||||
4. Remove the entire `speakLastAssistantMessage` function (lines ~38–59).
|
||||
|
||||
5. Remove the `watch(() => store.streaming, ...)` block that called `speakLastAssistantMessage` (lines ~61–66).
|
||||
|
||||
6. Add after `const listenMode = useListenMode();`:
|
||||
```typescript
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => store.streamingContent),
|
||||
streaming: computed(() => store.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template references**
|
||||
|
||||
In the ChatView template, replace every occurrence of `synthesising` with `tts.speaking.value`:
|
||||
|
||||
Find (line ~919):
|
||||
```html
|
||||
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }"
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': tts.speaking.value }"
|
||||
```
|
||||
|
||||
Find (line ~920):
|
||||
```html
|
||||
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()"
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
||||
```
|
||||
|
||||
Find (line ~924):
|
||||
```html
|
||||
<svg v-if="!synthesising && !audio.playing.value" ...>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<svg v-if="!tts.speaking.value" ...>
|
||||
```
|
||||
|
||||
Note: the `audio` variable (`useVoiceAudio()`) is still used for the volume slider and PTT stop — do NOT remove it.
|
||||
|
||||
- [ ] **Step 3: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/ChatView.vue
|
||||
git commit -m "feat(tts): wire useStreamingTts into ChatView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update BriefingView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/BriefingView.vue`
|
||||
|
||||
Current TTS code to remove:
|
||||
|
||||
```typescript
|
||||
// REMOVE:
|
||||
const synthesising = ref(false)
|
||||
|
||||
async function speakText(text: string) { ... } // entire function
|
||||
async function listenToLatest() { ... } // entire function
|
||||
|
||||
// REMOVE this watch block (the TTS one — keep the other streaming watch):
|
||||
watch(() => chatStore.streaming, async (streaming) => {
|
||||
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
await listenToLatest()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Note: BriefingView has **two** `watch(() => chatStore.streaming, ...)` blocks. Keep the first one (lines ~152–156, which refreshes messages). Remove only the TTS one (lines ~327–332).
|
||||
|
||||
Also remove the `synthesiseSpeech` import from `@/api/client`.
|
||||
|
||||
- [ ] **Step 1: Add import and replace TTS logic**
|
||||
|
||||
In `frontend/src/views/BriefingView.vue`:
|
||||
|
||||
1. Add to imports:
|
||||
```typescript
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
```
|
||||
|
||||
2. Remove `synthesiseSpeech` from the `@/api/client` import line.
|
||||
|
||||
3. Remove `const synthesising = ref(false)`.
|
||||
|
||||
4. Remove the entire `speakText` function.
|
||||
|
||||
5. Remove the entire `listenToLatest` function.
|
||||
|
||||
6. Remove the TTS `watch(() => chatStore.streaming, ...)` block (the one that calls `listenToLatest`).
|
||||
|
||||
7. Add after `const listenMode = useListenMode()`:
|
||||
```typescript
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => chatStore.streamingContent),
|
||||
streaming: computed(() => chatStore.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template references**
|
||||
|
||||
Find the listen toggle button in the template. Replace `synthesising` references:
|
||||
|
||||
```html
|
||||
<!-- Before -->
|
||||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
|
||||
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
|
||||
|
||||
<!-- After -->
|
||||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': tts.speaking.value }"
|
||||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
||||
```
|
||||
|
||||
Find the stop button:
|
||||
```html
|
||||
<!-- Before -->
|
||||
v-if="voiceTtsEnabled && (synthesising || audio.playing.value)"
|
||||
@click="audio.stop(); synthesising = false"
|
||||
|
||||
<!-- After -->
|
||||
v-if="voiceTtsEnabled && tts.speaking.value"
|
||||
@click="tts.stop()"
|
||||
```
|
||||
|
||||
Find the spinner SVG condition:
|
||||
```html
|
||||
<!-- Before -->
|
||||
<svg v-if="!synthesising && !audio.playing.value" ...>
|
||||
|
||||
<!-- After -->
|
||||
<svg v-if="!tts.speaking.value" ...>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/BriefingView.vue
|
||||
git commit -m "feat(tts): wire useStreamingTts into BriefingView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add streaming TTS to WorkspaceView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/WorkspaceView.vue`
|
||||
|
||||
WorkspaceView has no TTS today. We add: listen mode toggle, `useStreamingTts`, and the listen button in the chat input toolbar.
|
||||
|
||||
- [ ] **Step 1: Add imports and composable**
|
||||
|
||||
In `frontend/src/views/WorkspaceView.vue`, add to the import block at the top of `<script setup>`:
|
||||
|
||||
```typescript
|
||||
import { useListenMode } from '@/composables/useListenMode'
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||||
```
|
||||
|
||||
After the existing store setup code (after `const settingsStore = useSettingsStore()`), add:
|
||||
|
||||
```typescript
|
||||
const listenMode = useListenMode()
|
||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||||
const audio = useVoiceAudio()
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => chatStore.streamingContent),
|
||||
streaming: computed(() => chatStore.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add listen mode button to template**
|
||||
|
||||
In the `<div class="chat-input-area">` section (around line 365), add the listen button before the abort/send button:
|
||||
|
||||
```html
|
||||
<div class="chat-input-area">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
class="chat-input"
|
||||
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent… (Enter to send)'"
|
||||
rows="1"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
></textarea>
|
||||
<!-- Listen mode toggle (TTS) -->
|
||||
<button
|
||||
v-if="voiceTtsEnabled"
|
||||
class="btn-listen-ws"
|
||||
:class="{ 'btn-listen-ws--active': listenMode, 'btn-listen-ws--busy': tts.speaking.value }"
|
||||
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
|
||||
aria-label="Toggle listen mode"
|
||||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
||||
>
|
||||
<svg v-if="!tts.speaking.value" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="chatStore.streaming"
|
||||
class="btn-abort"
|
||||
title="Stop generation"
|
||||
@click="chatStore.cancelGeneration()"
|
||||
>
|
||||
■ Stop
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-send"
|
||||
:disabled="!messageInput.trim()"
|
||||
@click="sendMessage"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add CSS for the listen button**
|
||||
|
||||
In the `<style>` block, add:
|
||||
|
||||
```css
|
||||
.btn-listen-ws {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-listen-ws:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-listen-ws--active {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.btn-listen-ws--busy {
|
||||
color: var(--color-primary);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/WorkspaceView.vue
|
||||
git commit -m "feat(tts): add streaming TTS listen mode to WorkspaceView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Final integration check and push
|
||||
|
||||
- [ ] **Step 1: Full TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1
|
||||
```
|
||||
|
||||
Expected: zero errors.
|
||||
|
||||
- [ ] **Step 2: Verify no dead imports remain**
|
||||
|
||||
```bash
|
||||
grep -n "synthesiseSpeech\|speakLastAssistantMessage\|speakText\|listenToLatest\|synthesising" \
|
||||
frontend/src/views/ChatView.vue \
|
||||
frontend/src/views/BriefingView.vue \
|
||||
frontend/src/views/WorkspaceView.vue
|
||||
```
|
||||
|
||||
Expected: no matches (all replaced).
|
||||
|
||||
- [ ] **Step 3: Manual smoke test**
|
||||
|
||||
1. Enable voice in Admin → Config
|
||||
2. Open Chat, enable listen mode (speaker icon)
|
||||
3. Send a message and watch: audio should begin playing the first sentence while the LLM is still streaming the response
|
||||
4. Send another message mid-playback — previous audio should stop immediately
|
||||
5. Toggle listen mode off mid-response — audio stops, `tts.stop()` called
|
||||
6. Repeat in `/briefing` and `/workspace/:id`
|
||||
|
||||
- [ ] **Step 4: Push**
|
||||
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage check:**
|
||||
- ✅ Starts playing during generation (sentence-level queue, fires on each boundary)
|
||||
- ✅ Automatic when listen mode on (enabled computed = listenMode && voiceTtsEnabled)
|
||||
- ✅ ChatView updated
|
||||
- ✅ BriefingView updated
|
||||
- ✅ WorkspaceView added
|
||||
- ✅ One retry before skipping on failure
|
||||
- ✅ Failures logged via `console.warn` with sentence text and error
|
||||
- ✅ `stop()` on new message start (watch streaming → true)
|
||||
- ✅ Flush remaining buffer on stream end (watch streaming → false)
|
||||
- ✅ Fragments < 3 chars skipped
|
||||
- ✅ `abortId` prevents stale playback after stop
|
||||
|
||||
**Type consistency:**
|
||||
- `tts.speaking` is `ComputedRef<boolean>` — accessed as `tts.speaking.value` in templates ✅
|
||||
- `tts.stop()` called consistently across all three views ✅
|
||||
- `useStreamingTts` options match usage in all three call sites ✅
|
||||
- `audio` variable kept in ChatView (used by volume slider) — not removed ✅
|
||||
@@ -0,0 +1,695 @@
|
||||
# Article Reading Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `read_article` tool so the LLM can fetch any URL, fix the history builder so tool context survives follow-up turns, redesign the Discuss button to inject article content as a persisted tool exchange, and remove the RSS content character cap.
|
||||
|
||||
**Architecture:** Four independent changes executed in dependency order: (1) content cap removal, (2) `read_article` tool, (3) history builder fix (prerequisite for everything persisting across follow-ups), (4) Discuss endpoint + frontend. Each task is independently committable.
|
||||
|
||||
**Tech Stack:** Python/Quart, SQLAlchemy async, trafilatura (already installed), httpx (already installed), Vue 3 + TypeScript frontend.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| Action | Path | Responsibility |
|
||||
|---|---|---|
|
||||
| Modify | `src/fabledassistant/services/rss.py` | Remove `CONTENT_MAX_CHARS` truncation |
|
||||
| Modify | `src/fabledassistant/services/tools.py` | Add `_URL_TOOLS` list, add `read_article` to `get_tools_for_user`, add handler in `execute_tool` |
|
||||
| Modify | `src/fabledassistant/routes/chat.py` | Fix history builder to replay tool_calls |
|
||||
| Modify | `src/fabledassistant/services/chat.py` | Add `tool_calls` parameter to `add_message` |
|
||||
| Modify | `src/fabledassistant/routes/briefing.py` | Add `POST /api/briefing/articles/<item_id>/discuss` endpoint |
|
||||
| Modify | `frontend/src/views/BriefingView.vue` | Replace `discussArticle()` to call new endpoint |
|
||||
| Modify | `tests/test_rss_service.py` | Update truncation test, add no-truncation test |
|
||||
| Create | `tests/test_article_reading.py` | Tests for `read_article` tool and history builder |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Remove RSS content cap
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/rss.py:17-18,83,213`
|
||||
- Modify: `tests/test_rss_service.py:19-26`
|
||||
|
||||
The `CONTENT_MAX_CHARS = 50_000` constant and all uses of `[:CONTENT_MAX_CHARS]` are removed.
|
||||
Trafilatura extracts only article body text, so content is naturally bounded.
|
||||
|
||||
- [ ] **Step 1: Update the truncation test to assert no truncation**
|
||||
|
||||
In `tests/test_rss_service.py`, replace the existing `test_extract_item_truncates_content` test:
|
||||
|
||||
```python
|
||||
def test_extract_item_does_not_truncate_content():
|
||||
"""extract_item() should store content without truncation."""
|
||||
from fabledassistant.services.rss import extract_item
|
||||
long_text = "x" * 100_000
|
||||
entry = MagicMock()
|
||||
entry.get = lambda k, d="": {"summary": long_text, "title": "", "link": "", "id": "g"}.get(k, d)
|
||||
entry.content = []
|
||||
entry.published_parsed = None
|
||||
item = extract_item(entry)
|
||||
assert len(item["content"]) == 100_000
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to confirm it fails**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
||||
make test ARGS="tests/test_rss_service.py::test_extract_item_does_not_truncate_content -v"
|
||||
```
|
||||
|
||||
Expected: FAIL (current code truncates to 50_000).
|
||||
|
||||
- [ ] **Step 3: Remove CONTENT_MAX_CHARS from rss.py**
|
||||
|
||||
In `src/fabledassistant/services/rss.py`:
|
||||
|
||||
Remove lines 17–18:
|
||||
```python
|
||||
# Safety cap on stored content — effectively unlimited for typical articles
|
||||
CONTENT_MAX_CHARS = 50_000
|
||||
```
|
||||
|
||||
Change line 83 from:
|
||||
```python
|
||||
content = _html_to_text(content)[:CONTENT_MAX_CHARS]
|
||||
```
|
||||
to:
|
||||
```python
|
||||
content = _html_to_text(content)
|
||||
```
|
||||
|
||||
Change line 213 from:
|
||||
```python
|
||||
item.content = full_text[:CONTENT_MAX_CHARS]
|
||||
```
|
||||
to:
|
||||
```python
|
||||
item.content = full_text
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all rss tests**
|
||||
|
||||
```bash
|
||||
make test ARGS="tests/test_rss_service.py -v"
|
||||
```
|
||||
|
||||
Expected: all pass. The `test_extract_item_truncates_content` test name no longer exists (replaced in Step 1).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/rss.py tests/test_rss_service.py
|
||||
git commit -m "feat(rss): remove article content character cap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `read_article` tool
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/tools.py`
|
||||
- Create: `tests/test_article_reading.py`
|
||||
|
||||
The tool uses `_fetch_full_article` from `rss.py` (lazy import inside `execute_tool` to avoid circular dependencies). Added unconditionally to all users via a new `_URL_TOOLS` list.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `tests/test_article_reading.py`:
|
||||
|
||||
```python
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_success():
|
||||
"""read_article tool returns article content on success."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
with patch(
|
||||
"fabledassistant.services.rss._fetch_full_article",
|
||||
new=AsyncMock(return_value="Article text here."),
|
||||
):
|
||||
result = await execute_tool(
|
||||
user_id=1,
|
||||
tool_name="read_article",
|
||||
arguments={"url": "https://example.com/article"},
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "article_content"
|
||||
assert result["url"] == "https://example.com/article"
|
||||
assert result["content"] == "Article text here."
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_fetch_failure():
|
||||
"""read_article tool returns success=False when fetch returns None."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
with patch(
|
||||
"fabledassistant.services.rss._fetch_full_article",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
result = await execute_tool(
|
||||
user_id=1,
|
||||
tool_name="read_article",
|
||||
arguments={"url": "https://example.com/bad"},
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "Could not fetch" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_truncates_at_40k():
|
||||
"""read_article tool truncates content at 40_000 chars and sets truncated=True."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
long_content = "x" * 50_000
|
||||
with patch(
|
||||
"fabledassistant.services.rss._fetch_full_article",
|
||||
new=AsyncMock(return_value=long_content),
|
||||
):
|
||||
result = await execute_tool(
|
||||
user_id=1,
|
||||
tool_name="read_article",
|
||||
arguments={"url": "https://example.com/long"},
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert len(result["content"]) == 40_000
|
||||
assert result["truncated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_empty_url():
|
||||
"""read_article tool returns success=False when url is empty."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
result = await execute_tool(
|
||||
user_id=1,
|
||||
tool_name="read_article",
|
||||
arguments={"url": ""},
|
||||
)
|
||||
assert result["success"] is False
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
```bash
|
||||
make test ARGS="tests/test_article_reading.py -v"
|
||||
```
|
||||
|
||||
Expected: all 4 fail with "read_article not handled" or AttributeError.
|
||||
|
||||
- [ ] **Step 3: Add `_URL_TOOLS` list and register it in `get_tools_for_user`**
|
||||
|
||||
In `src/fabledassistant/services/tools.py`, add the `_URL_TOOLS` list immediately after the `_SEARCH_TOOLS` block (around line 836):
|
||||
|
||||
```python
|
||||
_URL_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_article",
|
||||
"description": (
|
||||
"Fetch and read the full text of a web page or article from a URL. "
|
||||
"Use when the user shares a URL and wants you to read it, or to get "
|
||||
"the full content of a linked page. "
|
||||
"Do NOT use search_web for URLs — use this tool instead."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "The URL to fetch and read"}
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In `get_tools_for_user` (around line 1034), add `_URL_TOOLS` unconditionally after `_CORE_TOOLS`:
|
||||
|
||||
```python
|
||||
async def get_tools_for_user(user_id: int) -> list[dict]:
|
||||
"""Build the tool list for a user based on their configured integrations."""
|
||||
tools = list(_CORE_TOOLS)
|
||||
tools.extend(_URL_TOOLS)
|
||||
tools.extend(_RAG_TOOLS)
|
||||
tools.extend(_ENTITY_TOOLS)
|
||||
if await is_caldav_configured(user_id):
|
||||
tools.extend(_CALDAV_TOOLS)
|
||||
if Config.searxng_enabled():
|
||||
tools.extend(_SEARCH_TOOLS)
|
||||
tools.extend(_RESEARCH_TOOLS)
|
||||
tools.extend(_IMAGE_TOOLS)
|
||||
logger.debug("User %d: %d tools available", user_id, len(tools))
|
||||
return tools
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `read_article` handler in `execute_tool`**
|
||||
|
||||
In `src/fabledassistant/services/tools.py`, in the `execute_tool` function, find the `elif tool_name == "search_web":` block (around line 1771). Add the new handler immediately before it:
|
||||
|
||||
```python
|
||||
elif tool_name == "read_article":
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
url = arguments.get("url", "").strip()
|
||||
if not url:
|
||||
return {"success": False, "error": "No URL provided"}
|
||||
content = await _fetch_full_article(url)
|
||||
if not content:
|
||||
return {"success": False, "error": f"Could not fetch article content from {url}"}
|
||||
_TOOL_CONTENT_CAP = 40_000
|
||||
truncated = len(content) > _TOOL_CONTENT_CAP
|
||||
return {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": url,
|
||||
"content": content[:_TOOL_CONTENT_CAP],
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests**
|
||||
|
||||
```bash
|
||||
make test ARGS="tests/test_article_reading.py -v"
|
||||
```
|
||||
|
||||
Expected: all 4 pass.
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/tools.py tests/test_article_reading.py
|
||||
git commit -m "feat(tools): add read_article tool using trafilatura extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fix history builder
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/routes/chat.py:162-166`
|
||||
- Modify: `tests/test_article_reading.py` (add history builder tests)
|
||||
|
||||
The loop that builds `history` for `run_generation` currently drops `tool_calls`. This fix replays the full tool exchange so the LLM sees prior tool results on follow-up turns.
|
||||
|
||||
- [ ] **Step 1: Add history builder tests**
|
||||
|
||||
Append to `tests/test_article_reading.py`:
|
||||
|
||||
```python
|
||||
def test_history_builder_plain_messages():
|
||||
"""Messages without tool_calls are added as {role, content} unchanged."""
|
||||
import json
|
||||
messages = [
|
||||
type("M", (), {"role": "system", "content": "sys", "tool_calls": None})(),
|
||||
type("M", (), {"role": "user", "content": "hello", "tool_calls": None})(),
|
||||
type("M", (), {"role": "assistant", "content": "hi", "tool_calls": None})(),
|
||||
]
|
||||
history = _build_history(messages)
|
||||
assert history == [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
|
||||
|
||||
def test_history_builder_with_tool_calls():
|
||||
"""Messages with tool_calls emit an assistant entry + tool result entries."""
|
||||
import json
|
||||
tool_calls_data = [
|
||||
{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": "https://example.com"},
|
||||
"result": {"success": True, "content": "Article text"},
|
||||
}
|
||||
]
|
||||
messages = [
|
||||
type("M", (), {"role": "user", "content": "read this", "tool_calls": None})(),
|
||||
type("M", (), {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": tool_calls_data,
|
||||
})(),
|
||||
type("M", (), {"role": "user", "content": "follow up", "tool_calls": None})(),
|
||||
]
|
||||
history = _build_history(messages)
|
||||
assert history[0] == {"role": "user", "content": "read this"}
|
||||
assert history[1]["role"] == "assistant"
|
||||
assert history[1]["tool_calls"] == [
|
||||
{"function": {"name": "read_article", "arguments": {"url": "https://example.com"}}}
|
||||
]
|
||||
assert history[2] == {"role": "tool", "content": json.dumps({"success": True, "content": "Article text"})}
|
||||
assert history[3] == {"role": "user", "content": "follow up"}
|
||||
|
||||
|
||||
def _build_history(messages):
|
||||
"""Inline copy of the fixed history builder for testing."""
|
||||
import json
|
||||
history = []
|
||||
for msg in messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
return history
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to confirm they pass**
|
||||
|
||||
(These tests use `_build_history` defined inline — they test the logic directly, not the route. They should pass immediately.)
|
||||
|
||||
```bash
|
||||
make test ARGS="tests/test_article_reading.py::test_history_builder_plain_messages tests/test_article_reading.py::test_history_builder_with_tool_calls -v"
|
||||
```
|
||||
|
||||
Expected: both pass.
|
||||
|
||||
- [ ] **Step 3: Apply the fix to `chat.py`**
|
||||
|
||||
In `src/fabledassistant/routes/chat.py`, replace lines 162–166:
|
||||
|
||||
```python
|
||||
# Build history from existing messages (excluding system and the placeholder)
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role != "system":
|
||||
history.append({"role": msg.role, "content": msg.content})
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
# Build history from existing messages (excluding system and the placeholder).
|
||||
# Tool calls from prior turns are replayed as assistant tool_call + tool result
|
||||
# messages so the LLM retains tool context on follow-up turns.
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
```
|
||||
|
||||
`json` is already imported at the top of `chat.py`.
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/routes/chat.py tests/test_article_reading.py
|
||||
git commit -m "fix(chat): replay tool_calls in history so tool context survives follow-up turns"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Extend `add_message` to accept `tool_calls`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/chat.py:183-207`
|
||||
|
||||
The Discuss endpoint (Task 5) needs to store a synthetic assistant message with `tool_calls`. The existing `add_message` doesn't support this parameter.
|
||||
|
||||
- [ ] **Step 1: Update `add_message` signature and body**
|
||||
|
||||
In `src/fabledassistant/services/chat.py`, replace the `add_message` function (lines 183–207):
|
||||
|
||||
```python
|
||||
async def add_message(
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
context_note_id: int | None = None,
|
||||
status: str | None = None,
|
||||
tool_calls: list | None = None,
|
||||
) -> Message:
|
||||
async with async_session() as session:
|
||||
kwargs: dict = dict(
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
context_note_id=context_note_id,
|
||||
)
|
||||
if status is not None:
|
||||
kwargs["status"] = status
|
||||
if tool_calls is not None:
|
||||
kwargs["tool_calls"] = tool_calls
|
||||
msg = Message(**kwargs)
|
||||
session.add(msg)
|
||||
# Touch conversation updated_at
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv:
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all pass (existing callers only use positional/keyword args that are unchanged).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/chat.py
|
||||
git commit -m "feat(chat): add tool_calls parameter to add_message"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add Discuss endpoint and update frontend
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/routes/briefing.py`
|
||||
- Modify: `frontend/src/views/BriefingView.vue`
|
||||
|
||||
New route: `POST /api/briefing/articles/<item_id>/discuss`. Fetches stored article from DB, stores a synthetic `read_article` tool exchange plus the user message, then triggers generation. Frontend replaces the inline-content approach with a call to this endpoint.
|
||||
|
||||
- [ ] **Step 1: Add the discuss endpoint to briefing.py**
|
||||
|
||||
At the top of `src/fabledassistant/routes/briefing.py`, add these imports (after the existing imports):
|
||||
|
||||
```python
|
||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||
from fabledassistant.services.chat import add_message, get_conversation
|
||||
from fabledassistant.services.generation_buffer import GenerationState, create_buffer, get_buffer
|
||||
from fabledassistant.services.generation_task import run_generation
|
||||
from fabledassistant.services.settings import get_setting
|
||||
```
|
||||
|
||||
Note: `get_setting` and `asyncio` are already imported. Add only what is missing.
|
||||
|
||||
Then add the new route at the end of `briefing.py` (before any final lines), after the `list_news` route:
|
||||
|
||||
```python
|
||||
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def discuss_article(item_id: int):
|
||||
"""Pre-load a briefing article as a read_article tool exchange and trigger generation."""
|
||||
uid = g.user.id
|
||||
data = await request.get_json() or {}
|
||||
conv_id = data.get("conv_id")
|
||||
if not conv_id:
|
||||
return jsonify({"error": "conv_id is required"}), 400
|
||||
|
||||
# Verify article belongs to this user (via feed ownership)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem).join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if item is None:
|
||||
return jsonify({"error": "Article not found"}), 404
|
||||
|
||||
# Verify conversation belongs to this user
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
# Reject if generation already running
|
||||
existing = get_buffer(conv_id)
|
||||
if existing and existing.state == GenerationState.RUNNING:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
article_content = item.content or ""
|
||||
|
||||
# Store synthetic assistant message: read_article was already called with stored content
|
||||
synthetic_tool_calls = [
|
||||
{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
|
||||
# Store user message
|
||||
await add_message(conv_id, "user", "Please summarize and discuss this article.")
|
||||
|
||||
# Reload conversation so history includes the two new messages
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
|
||||
# Build history (using the fixed builder from chat.py logic — duplicated here)
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
from fabledassistant.config import Config as _Config
|
||||
if not model:
|
||||
model = _Config.OLLAMA_MODEL
|
||||
|
||||
# Create placeholder assistant message and generation buffer
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
try:
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
except RuntimeError:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title,
|
||||
"Please summarize and discuss this article.",
|
||||
think=True,
|
||||
))
|
||||
|
||||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 3: Update `discussArticle` in BriefingView.vue**
|
||||
|
||||
In `frontend/src/views/BriefingView.vue`, replace the `discussArticle` function:
|
||||
|
||||
```typescript
|
||||
async function discussArticle(item: NewsItem) {
|
||||
if (!todayConvId.value || chatStore.streaming) return
|
||||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
||||
await nextTick(() => {
|
||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
try {
|
||||
await apiPost<{ assistant_message_id: number }>(
|
||||
`/api/briefing/articles/${item.id}/discuss`,
|
||||
{ conv_id: todayConvId.value },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// Reload conversation so the new messages appear (including the generating placeholder),
|
||||
// then reconnect to the SSE stream using the existing reconnectIfGenerating helper.
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
await chatStore.reconnectIfGenerating(todayConvId.value)
|
||||
}
|
||||
```
|
||||
|
||||
`reconnectIfGenerating` is already exported from `useChatStore`. It finds the assistant message in `status="generating"` state and connects to the SSE stream automatically. No changes to `chat.ts` are needed.
|
||||
|
||||
- [ ] **Step 4: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
||||
npm --prefix frontend run type-check
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/routes/briefing.py frontend/src/views/BriefingView.vue frontend/src/stores/chat.ts
|
||||
git commit -m "feat(briefing): add discuss endpoint and update frontend to use persisted article context"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final verification
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: TypeScript check**
|
||||
|
||||
```bash
|
||||
npm --prefix frontend run type-check
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 3: Push**
|
||||
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
@@ -0,0 +1,479 @@
|
||||
# Web Voice Overlay Polish — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it in `App.vue`, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection backed by a new `useSilenceDetector` composable.
|
||||
|
||||
**Architecture:** A new `useSilenceDetector` composable uses `AudioContext` + `AnalyserNode` to monitor amplitude from a live `MediaStream` and fires a callback after sustained silence. `VoiceOverlay` coordinates recording and silence detection, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds a Space bar handler that dispatches the existing `voice:ptt-toggle` custom event.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, TypeScript, Web Audio API (`AudioContext`, `AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Create | `frontend/src/composables/useSilenceDetector.ts` |
|
||||
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
|
||||
| Modify | `frontend/src/components/VoiceOverlay.vue` |
|
||||
| Modify | `frontend/src/App.vue` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `useSilenceDetector` composable
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useSilenceDetector.ts`
|
||||
|
||||
**Context:** The Web Audio API lets us pipe a `MediaStream` into an `AnalyserNode` and read frequency data as a byte array every 100 ms. RMS amplitude of that array gives a 0–1 loudness value; converting to dB lets us use the same `-40 dB` threshold as the Android app. The composable must be safe to call `stop()` on multiple times and must reset amplitude to 0 after stopping so the animated bars collapse.
|
||||
|
||||
- [ ] **Step 1: Create the file with full implementation**
|
||||
|
||||
`frontend/src/composables/useSilenceDetector.ts`:
|
||||
|
||||
```ts
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
thresholdDb = -40,
|
||||
silenceDurationMs = 1500,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
const amplitude = ref(0)
|
||||
let audioCtx: AudioContext | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let silenceMs = 0
|
||||
let startedAt = 0
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void): void {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
analyser.getByteFrequencyData(data)
|
||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
||||
amplitude.value = rms
|
||||
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
||||
if (db < thresholdDb) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
||||
stop()
|
||||
onSilence()
|
||||
}
|
||||
} else {
|
||||
silenceMs = 0
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close().catch(() => {})
|
||||
audioCtx = null
|
||||
}
|
||||
amplitude.value = 0
|
||||
silenceMs = 0
|
||||
}
|
||||
|
||||
return { amplitude: readonly(amplitude), start, stop }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /path/to/fabledassistant/frontend
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useSilenceDetector.ts
|
||||
git commit -m "feat: add useSilenceDetector composable with Web Audio API amplitude monitoring"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Expose `stream` ref from `useVoiceRecorder`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
|
||||
|
||||
**Context:** Currently `stream` is a plain `let` variable inside the closure. `VoiceOverlay` needs to pass the live `MediaStream` to `useSilenceDetector.start()` after recording begins. Exposing it as a readonly `Ref<MediaStream | null>` is the minimal change — no other callers are broken because they don't currently read `stream` from the return value.
|
||||
|
||||
The current file is at `frontend/src/composables/useVoiceRecorder.ts`. Read it before editing — the key lines to change are:
|
||||
|
||||
1. Top of function body: `let stream: MediaStream | null = null` → `const streamRef = ref<MediaStream | null>(null)`
|
||||
2. In `startRecording()`: `stream = await navigator.mediaDevices.getUserMedia({ audio: true })` → `streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })`
|
||||
3. In `startRecording()` catch block: `stream = null` if present — replace with `streamRef.value = null` (if the catch sets stream to null; if not, skip)
|
||||
4. In `mediaRecorder.onstop`: `stream?.getTracks().forEach((t) => t.stop())` → `streamRef.value?.getTracks().forEach((t) => t.stop())` then `streamRef.value = null`
|
||||
5. Return object: add `stream: readonly(streamRef)`
|
||||
|
||||
- [ ] **Step 1: Add the `ref` import if not already present**
|
||||
|
||||
The file already imports `{ ref, readonly }` from `'vue'` — confirm this. If `ref` is missing from the import, add it.
|
||||
|
||||
- [ ] **Step 2: Replace the `stream` variable declaration**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
let stream: MediaStream | null = null
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
const streamRef = ref<MediaStream | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update all usages of `stream` in `startRecording`**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `onstop` handler**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
stream = null
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
streamRef.value?.getTracks().forEach((t) => t.stop())
|
||||
streamRef.value = null
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `stream` to the return object**
|
||||
|
||||
Find the return statement and add `stream: readonly(streamRef)`:
|
||||
```ts
|
||||
return {
|
||||
recording: readonly(recording),
|
||||
error: readonly(error),
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
stream: readonly(streamRef),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useVoiceRecorder.ts
|
||||
git commit -m "feat: expose live stream ref from useVoiceRecorder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update `VoiceOverlay` — silence detection, click-to-toggle, amplitude bars
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/VoiceOverlay.vue`
|
||||
|
||||
**Context:** `VoiceOverlay.vue` is a complete floating voice UI that was never mounted. It currently uses `@mousedown`/`@mouseup` for push-to-talk. This task switches it to click-to-toggle with automatic silence detection and adds animated amplitude bars during recording. Read the full file before making changes — the existing structure and style blocks must be preserved.
|
||||
|
||||
#### Script changes
|
||||
|
||||
- [ ] **Step 1: Import `useSilenceDetector`**
|
||||
|
||||
At the top of `<script setup>`, after the existing imports, add:
|
||||
```ts
|
||||
import { useSilenceDetector } from '@/composables/useSilenceDetector'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Instantiate the composable**
|
||||
|
||||
After `const audio = useVoiceAudio()`, add:
|
||||
```ts
|
||||
const silenceDetector = useSilenceDetector()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `startPtt` to start silence detection**
|
||||
|
||||
Find the `startPtt` function. After `phase.value = 'recording'`, add:
|
||||
```ts
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
||||
}
|
||||
```
|
||||
|
||||
The complete `startPtt` after the change:
|
||||
```ts
|
||||
async function startPtt() {
|
||||
if (!voiceEnabled.value || isBusy.value) return
|
||||
audio.stop()
|
||||
errorMsg.value = ''
|
||||
open.value = true
|
||||
await recorder.startRecording()
|
||||
if (recorder.error.value) {
|
||||
phase.value = 'error'
|
||||
errorMsg.value = recorder.error.value
|
||||
return
|
||||
}
|
||||
phase.value = 'recording'
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `stopPtt` to stop silence detection**
|
||||
|
||||
Add `silenceDetector.stop()` as the very first line of `stopPtt`:
|
||||
```ts
|
||||
async function stopPtt() {
|
||||
silenceDetector.stop()
|
||||
if (phase.value !== 'recording') return
|
||||
// ... rest unchanged
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `cancelAll` to stop silence detection**
|
||||
|
||||
Add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`:
|
||||
```ts
|
||||
function cancelAll() {
|
||||
silenceDetector.stop()
|
||||
recorder.stopRecording().catch(() => {})
|
||||
audio.stop()
|
||||
phase.value = 'idle'
|
||||
streamContent.value = ''
|
||||
errorMsg.value = ''
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add `onBtnClick` function**
|
||||
|
||||
Add this function after `cancelAll`:
|
||||
```ts
|
||||
function onBtnClick() {
|
||||
if (phase.value === 'error') { phase.value = 'idle'; return }
|
||||
if (phase.value === 'recording') { stopPtt(); return }
|
||||
if (phase.value === 'idle') { startPtt() }
|
||||
}
|
||||
```
|
||||
|
||||
#### Template changes
|
||||
|
||||
- [ ] **Step 7: Replace PTT mouse/touch handlers with `@click`**
|
||||
|
||||
On `.voice-ptt-btn`, replace:
|
||||
```html
|
||||
@mousedown.prevent="startPtt"
|
||||
@mouseup.prevent="stopPtt"
|
||||
@touchstart.prevent="startPtt"
|
||||
@touchend.prevent="stopPtt"
|
||||
@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"
|
||||
```
|
||||
with:
|
||||
```html
|
||||
@click.prevent="onBtnClick"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update aria-label and title on the button**
|
||||
|
||||
Replace:
|
||||
```html
|
||||
:aria-label="phase === 'recording' ? 'Release to send' : 'Hold to speak'"
|
||||
:title="phase === 'recording' ? 'Release to send' : 'Hold Space or tap to speak'"
|
||||
```
|
||||
with:
|
||||
```html
|
||||
:aria-label="phase === 'recording' ? 'Click to stop' : 'Click to speak'"
|
||||
:title="phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'"
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Replace the static recording icon with amplitude bars**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<!-- Recording: waveform / stop icon -->
|
||||
<svg v-else-if="phase === 'recording'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<!-- Recording: amplitude bars -->
|
||||
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
|
||||
<span
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="voice-amp-bar"
|
||||
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
|
||||
></span>
|
||||
</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Update the idle hint label**
|
||||
|
||||
Find:
|
||||
```html
|
||||
Hold <kbd>Space</kbd> or tap
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
Tap or press <kbd>Space</kbd>
|
||||
```
|
||||
|
||||
#### Style changes
|
||||
|
||||
- [ ] **Step 11: Add amplitude bar styles to `<style scoped>`**
|
||||
|
||||
Append inside the `<style scoped>` block:
|
||||
```css
|
||||
/* ─── Amplitude bars (recording state) ──────────────────────────────────── */
|
||||
.voice-amp-bars {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
}
|
||||
.voice-amp-bar {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
transform-origin: center;
|
||||
transition: transform 0.08s ease;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 12: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/VoiceOverlay.vue
|
||||
git commit -m "feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Mount `VoiceOverlay` and wire Space bar in `App.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.vue`
|
||||
|
||||
**Context:** `App.vue` has a full `onGlobalKeydown` handler and a shortcuts overlay. The Space bar is already documented there as "Hold to speak (voice, when enabled)" but the handler was never added to `onGlobalKeydown`. `VoiceOverlay` uses `Teleport to="body"` so it renders at the document root regardless of where it's placed in the template — just needs to be inside the authenticated block.
|
||||
|
||||
#### Script changes
|
||||
|
||||
- [ ] **Step 1: Add `VoiceOverlay` import**
|
||||
|
||||
In `<script setup>`, after the existing component imports (after `ToastNotification`), add:
|
||||
```ts
|
||||
import VoiceOverlay from '@/components/VoiceOverlay.vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Space bar case to `onGlobalKeydown`**
|
||||
|
||||
The existing handler has a `switch (e.key)` block. The guard `if (isInputActive() || e.ctrlKey || e.metaKey || e.altKey) return` already runs before the switch, so the Space case only fires when the user isn't typing.
|
||||
|
||||
Inside the `switch (e.key)` block, add this case after the existing `'c'` case:
|
||||
```ts
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
||||
break
|
||||
```
|
||||
|
||||
#### Template changes
|
||||
|
||||
- [ ] **Step 3: Mount `VoiceOverlay` in the authenticated template**
|
||||
|
||||
Find `<ToastNotification />` near the bottom of the authenticated template block and add `<VoiceOverlay />` directly above it:
|
||||
```html
|
||||
<VoiceOverlay />
|
||||
<ToastNotification />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update Space bar description in shortcuts panel**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<span class="shortcut-desc">Hold to speak (voice, when enabled)</span>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<span class="shortcut-desc">Tap to speak (voice, when enabled)</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 6: Verify full build succeeds**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: build completes with no errors.
|
||||
|
||||
- [ ] **Step 7: Manual smoke test**
|
||||
|
||||
1. Start the dev server: `npm run dev`
|
||||
2. Log in — confirm the floating mic button appears in the bottom-right corner
|
||||
3. Ensure voice is enabled in Settings → Voice
|
||||
4. Click the mic button — confirm it turns red with animated amplitude bars
|
||||
5. Speak — bars should animate with your voice
|
||||
6. Stop speaking — after ~1.5 s of silence, the button should switch to purple (transcribing), then green (speaking) as it plays back the response
|
||||
7. Click the mic while recording — confirm it stops immediately
|
||||
8. Press Space (not in an input field) — confirm it starts/stops recording
|
||||
9. Press Space in the chat input — confirm it does NOT trigger voice
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/App.vue
|
||||
git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut in App.vue"
|
||||
```
|
||||
@@ -0,0 +1,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,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,216 @@
|
||||
# ChatPanel Unification Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the four divergent chat surfaces (ChatView, BriefingView, WorkspaceView, HomeView widget) with a single `ChatPanel` component that encapsulates all chat behaviour — streaming, TTS, PTT, tool calls, thinking blocks, abort — so that fixes and features automatically apply to every context.
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The app currently has four independent chat implementations that have drifted significantly:
|
||||
|
||||
| Surface | File | Gap |
|
||||
|---|---|---|
|
||||
| Main chat | `ChatView.vue` | Canonical reference |
|
||||
| Briefing | `BriefingView.vue` | Had separate TTS impl (now fixed), no PTT, streaming race bug |
|
||||
| Workspace | `WorkspaceView.vue` | TTS missing until recently, different input wiring |
|
||||
| Dashboard widget | `HomeView.vue` + `DashboardChatInput.vue` | Separate input component, response rendered manually in parent, no TTS, no PTT |
|
||||
|
||||
Every fix to chat has required touching 3–4 files. This design makes chat a first-class component.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component: `ChatPanel.vue`
|
||||
|
||||
A single Vue 3 component that owns the entire chat interaction loop for a given conversation context. Two variants controlled by a `variant` prop:
|
||||
|
||||
- **`full`** — full-height chat: message history, streaming bubble, input bar, all controls
|
||||
- **`widget`** — compact embedded chat: input bar + compact response area, no history scroll
|
||||
|
||||
Both variants share identical internals: same composables, same store reads, same TTS/PTT/abort logic.
|
||||
|
||||
### Extracted Sub-components
|
||||
|
||||
| Component | Responsibility |
|
||||
|---|---|
|
||||
| `ChatInputBar.vue` | Unified input bar: textarea, note picker, PTT mic, send button, abort button |
|
||||
| `ChatMessageList.vue` | Scrollable message history with auto-scroll, bulk-select (full variant only) |
|
||||
| `ChatStreamingBubble.vue` | Live streaming content display + thinking block |
|
||||
| `ChatToolCallList.vue` | Tool call cards, collapsed/expanded state |
|
||||
|
||||
### State Ownership
|
||||
|
||||
`ChatPanel` reads from `useChatStore` directly — it does not accept messages or streaming state as props. This mirrors how all current views work and avoids prop-drilling re-implementation.
|
||||
|
||||
The conversation being displayed is controlled via a `convId` prop. When `convId` is undefined, `ChatPanel` uses `chatStore.currentConversationId`. The parent view sets up the conversation (creates it if needed) and passes the ID down.
|
||||
|
||||
---
|
||||
|
||||
## Props & Emits Interface
|
||||
|
||||
```typescript
|
||||
interface ChatPanelProps {
|
||||
variant: 'full' | 'widget'
|
||||
convId?: number // which conversation to display; undefined = store current
|
||||
projectId?: number // workspace: pins RAG scope, passed to sendMessage
|
||||
briefingMode?: boolean // briefing: hides RAG scope chip, enables briefing-specific send path
|
||||
placeholder?: string // input placeholder text
|
||||
autoFocus?: boolean // focus input on mount
|
||||
}
|
||||
|
||||
interface ChatPanelEmits {
|
||||
// Emitted when a new conversation is started from the widget (so parent can track convId)
|
||||
(e: 'conversation-started', convId: number): void
|
||||
}
|
||||
```
|
||||
|
||||
All other behaviour (TTS, PTT, thinking, tool calls, streaming indicator, abort) is always on — not gated by props. The intentional differences between views are expressed only through the props above.
|
||||
|
||||
---
|
||||
|
||||
## Variant Behaviour
|
||||
|
||||
### `variant="full"` (ChatView, BriefingView, WorkspaceView)
|
||||
|
||||
Layout (top to bottom):
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ [RAG scope chip / briefing header] │ ← shown unless briefingMode or projectId set
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ ChatMessageList │
|
||||
│ user bubble │
|
||||
│ assistant bubble + tool calls │
|
||||
│ thinking block (always shown) │
|
||||
│ ... │
|
||||
│ ChatStreamingBubble (while streaming) │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ ChatInputBar │
|
||||
│ [textarea] [note-picker] [mic] [▶] │
|
||||
│ [listen toggle] [abort] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### `variant="widget"` (HomeView dashboard)
|
||||
|
||||
Layout (top to bottom, compact):
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ ChatInputBar (pill style) │
|
||||
│ [textarea] [mic] [▶] │
|
||||
├────────────────────────────────────────┤
|
||||
│ [query text] (after send) │
|
||||
│ [streaming / final response text] │
|
||||
│ [tool call chips] │
|
||||
│ [Continue in Chat →] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The widget variant does NOT show full message history. It shows only the most recent exchange. Once a new conversation is started or the user navigates to `/chat/:id`, the full history is available.
|
||||
|
||||
The `.dashboard-response` section currently in `HomeView.vue` moves inside `ChatPanel` and is rendered when `variant="widget"` and a conversation exists.
|
||||
|
||||
---
|
||||
|
||||
## TTS / PTT Wiring
|
||||
|
||||
`ChatPanel` instantiates `useStreamingTts` and `useListenMode` internally. These are not passed as props.
|
||||
|
||||
```typescript
|
||||
// Inside ChatPanel setup()
|
||||
const listenMode = useListenMode()
|
||||
const voiceTtsEnabled = computed(() => /* same check as current views */)
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => chatStore.streamingContent),
|
||||
streaming: computed(() => !!chatStore.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
```
|
||||
|
||||
PTT is handled inside `ChatInputBar` via the existing `useVoiceRecorder` composable (already used in `DashboardChatInput`). On recording stop, the transcribed text is placed in the textarea and auto-submitted.
|
||||
|
||||
---
|
||||
|
||||
## Per-View Migration
|
||||
|
||||
### ChatView → `<ChatPanel variant="full">`
|
||||
- Remove: all TTS/PTT/streaming/abort logic, scroll management, input bar template
|
||||
- Keep: route wiring, conversation list sidebar, bulk-delete UI (sidebar stays in ChatView)
|
||||
- ChatPanel replaces only the right-hand panel
|
||||
|
||||
### BriefingView → `<ChatPanel variant="full" briefingMode />`
|
||||
- Remove: streaming watch, TTS, manual scroll, input bar, response persistence workaround
|
||||
- Keep: history dropdown (today / past briefings), date header
|
||||
- `briefingMode` hides the RAG scope chip
|
||||
|
||||
### WorkspaceView → `<ChatPanel variant="full" :projectId="projectId">`
|
||||
- Remove: inline chat input, streaming watch, TTS wiring
|
||||
- Keep: 3-panel grid layout, task panel, note editor panel
|
||||
- ChatPanel takes the centre column
|
||||
|
||||
### HomeView → `<ChatPanel variant="widget">`
|
||||
- Remove: `DashboardChatInput` import + usage, `.dashboard-response` section, all manual store wiring (`dashboardConvId`, `dashboardQuery`, `dashboardFinalContent`, `dashboardFinalToolCalls`, `onChatSubmit`)
|
||||
- Keep: dashboard layout, projects/tasks/events sections
|
||||
- `DashboardChatInput.vue` deleted
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Parent view
|
||||
└─ <ChatPanel :convId="convId" variant="full|widget">
|
||||
├─ reads: useChatStore (messages, streaming, streamingContent, currentConversation)
|
||||
├─ ChatMessageList — renders history from store
|
||||
├─ ChatStreamingBubble — renders chatStore.streamingContent while streaming
|
||||
├─ ChatToolCallList — renders tool calls from streaming + finalized messages
|
||||
├─ ChatInputBar
|
||||
│ ├─ usePtt (mic → textarea → auto-send)
|
||||
│ └─ emits: submit(content, contextNoteId)
|
||||
├─ useStreamingTts (sentence-chunk TTS during streaming)
|
||||
└─ useListenMode (shared global toggle)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Created / Modified
|
||||
|
||||
**Created:**
|
||||
- `frontend/src/components/ChatPanel.vue`
|
||||
- `frontend/src/components/ChatInputBar.vue`
|
||||
- `frontend/src/components/ChatMessageList.vue`
|
||||
- `frontend/src/components/ChatStreamingBubble.vue`
|
||||
- (no new composable needed — PTT uses existing `useVoiceRecorder.ts`)
|
||||
|
||||
**Modified:**
|
||||
- `frontend/src/views/ChatView.vue` — use ChatPanel for the chat area
|
||||
- `frontend/src/views/BriefingView.vue` — replace chat section with ChatPanel
|
||||
- `frontend/src/views/WorkspaceView.vue` — replace inline chat with ChatPanel
|
||||
- `frontend/src/views/HomeView.vue` — replace DashboardChatInput + response section with ChatPanel widget
|
||||
|
||||
**Deleted:**
|
||||
- `frontend/src/components/DashboardChatInput.vue`
|
||||
|
||||
---
|
||||
|
||||
## CSS / Styling
|
||||
|
||||
- `ChatPanel` carries its own scoped CSS for both variants
|
||||
- `ChatInputBar` replicates the pill style currently in `DashboardChatInput` and the flat style in `ChatView` — variant is controlled by a `pill` boolean prop (default false; widget sets it true)
|
||||
- All existing UI design language tokens (`--color-primary`, `--radius-lg`, Fraunces labels, gradient send button) are preserved
|
||||
|
||||
---
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- Chat store (`useChatStore`) — unchanged
|
||||
- API client (`client.ts`) — unchanged
|
||||
- Backend routes — unchanged
|
||||
- WorkspaceTaskPanel and WorkspaceNoteEditor — unchanged
|
||||
- Briefing history dropdown and date header — unchanged
|
||||
- ChatView conversation sidebar and bulk-delete — unchanged
|
||||
- RAG scope chip logic — moved inside ChatPanel, behaviour identical
|
||||
@@ -0,0 +1,101 @@
|
||||
# Streaming TTS Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Start playing TTS audio during LLM generation rather than waiting for the full response to finish. When listen mode is on, the first sentence plays as soon as Kokoro finishes synthesizing it — while the LLM is still streaming the rest of the response.
|
||||
|
||||
## Approach
|
||||
|
||||
Client-side sentence queuing composable. The frontend accumulates streaming tokens, detects sentence boundaries, fires per-sentence synthesis requests concurrently, and plays audio in strict insertion order. The existing `/api/voice/synthesise` backend endpoint is unchanged.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `useStreamingTts` composable
|
||||
|
||||
**File:** `frontend/src/composables/useStreamingTts.ts`
|
||||
|
||||
**Inputs:**
|
||||
- `streamingContent: Ref<string>` — the growing accumulated response text (e.g. `store.streamingContent`)
|
||||
- `streaming: Ref<boolean>` — whether the LLM is currently generating
|
||||
- `enabled: Ref<boolean>` — `true` when listen mode is on AND TTS is available
|
||||
|
||||
**Exports:**
|
||||
- `speaking: Ref<boolean>` — `true` while any synthesis is in-flight or audio is playing
|
||||
- `stop()` — cancels all pending synthesis/playback and clears the queue
|
||||
|
||||
**Internal state:**
|
||||
- `sentenceBuffer: string` — accumulates characters since the last dispatched sentence
|
||||
- `lastSeenLength: number` — tracks how far into `streamingContent` we've processed
|
||||
- `abortId: number` — incremented on `stop()`; each queued promise checks the current id and bails if stale
|
||||
- `playQueue: Promise<void>` — a chained promise that serializes audio playback in insertion order
|
||||
|
||||
**Sentence detection:**
|
||||
- Regex: `/[.!?]+(?=\s|$)/` — handles `...`, `?!`, multi-punctuation
|
||||
- Triggered on every `streamingContent` change and on `streaming` flipping `false` (flush)
|
||||
- Fragments < 3 characters after markdown stripping are skipped
|
||||
|
||||
**Per-sentence pipeline:**
|
||||
1. Strip markdown (same logic as current `speakLastAssistantMessage`)
|
||||
2. Fire `synthesiseSpeech(sentence)` immediately — runs concurrently with other sentences
|
||||
3. On failure: one immediate retry. If retry also fails, skip silently and advance the queue
|
||||
4. Resolved blob is inserted into the playback queue at its original position
|
||||
5. Playback queue plays blobs strictly in insertion order via `useVoiceAudio`
|
||||
|
||||
**Stream-end flush:**
|
||||
- When `streaming` flips `false`, any remaining `sentenceBuffer` content (fragment without terminal punctuation) is dispatched as a final sentence — covers responses that end without a period
|
||||
|
||||
**Automatic reset:**
|
||||
- When `streaming` flips `true` (new message starting), `stop()` is called automatically to cancel any in-flight audio from the previous response before starting fresh
|
||||
|
||||
### Views updated
|
||||
|
||||
| View | Change |
|
||||
|------|--------|
|
||||
| `ChatView.vue` | Replace `speakLastAssistantMessage()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
|
||||
| `BriefingView.vue` | Replace `speakText()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
|
||||
| `WorkspaceView.vue` | Add listen mode toggle button (same UI pattern as ChatView) + `useStreamingTts` wired to workspace chat stream |
|
||||
|
||||
In all three views: the `speaking` export from `useStreamingTts` replaces the old `synthesising || audio.playing.value` checks for button busy state.
|
||||
|
||||
### Backend
|
||||
|
||||
No changes. `/api/voice/synthesise` accepts shorter sentence-length strings without issue.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Synthesis fails for a sentence | One immediate retry; if retry fails, sentence is skipped, queue advances, and a `console.warn` is emitted with the sentence index and error |
|
||||
| `stop()` called mid-queue | `abortId` incremented; all in-flight promises check id and discard their result |
|
||||
| New message starts while audio playing | `watch(streaming, true → ...)` calls `stop()` before starting new queue |
|
||||
| TTS unavailable or listen mode off | Composable is inert — watchers do nothing, no requests fired |
|
||||
| Fragment < 3 chars after stripping | Skipped without a TTS request |
|
||||
| Response ends without terminal punctuation | Remaining buffer flushed as final sentence on stream-end |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
LLM SSE chunks → store.streamingContent (grows)
|
||||
↓
|
||||
useStreamingTts watcher
|
||||
↓
|
||||
sentenceBuffer accumulation
|
||||
↓
|
||||
sentence boundary detected? → synthesiseSpeech(sentence) [concurrent]
|
||||
↓ ↓ (fail → 1 retry → skip)
|
||||
playQueue.then(play blob) resolved blob
|
||||
↓
|
||||
useVoiceAudio.play() [sequential]
|
||||
↓
|
||||
audio output
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
- **New:** `frontend/src/composables/useStreamingTts.ts`
|
||||
- **Modified:** `frontend/src/views/ChatView.vue` — swap TTS logic for composable
|
||||
- **Modified:** `frontend/src/views/BriefingView.vue` — swap TTS logic for composable
|
||||
- **Modified:** `frontend/src/views/WorkspaceView.vue` — add listen mode + composable
|
||||
@@ -0,0 +1,227 @@
|
||||
# Article Reading Design
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Allow the LLM to fetch and read the full text of any URL on demand, fix conversation history so tool context survives follow-up turns, and make the briefing Discuss button inject article content as a persisted tool exchange rather than raw user-message text.
|
||||
|
||||
**Architecture:** Four self-contained changes — history reconstruction fix (prerequisite), `read_article` tool, Discuss endpoint, and content cap removal.
|
||||
|
||||
**Tech Stack:** Python/Quart backend, trafilatura (already installed), SQLAlchemy async, Vue 3 frontend.
|
||||
|
||||
---
|
||||
|
||||
## Problem summary
|
||||
|
||||
Three interrelated issues observed in briefing conversations:
|
||||
|
||||
1. **Missing `read_article` tool** — when a user pastes a URL, the LLM calls `search_web` (a SearXNG text search), which returns generic site descriptions instead of article content.
|
||||
2. **History reconstruction bug** — `routes/chat.py:166` builds the `history` list with only `role` + `content`, silently dropping all `tool_calls` and their results from prior turns. Tool context is lost on every follow-up.
|
||||
3. **Discuss button UX** — inlines raw article text into the user message bubble. Feels clumsy, and the model sometimes searches notes on follow-ups anyway because the article isn't clearly marked as "loaded" context.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### 1. History reconstruction fix
|
||||
**File:** `src/fabledassistant/routes/chat.py`
|
||||
|
||||
The loop at line ~164 that builds `history` must be updated to replay tool exchanges:
|
||||
|
||||
```python
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
```
|
||||
|
||||
The `tool_calls` JSONB column already stores `[{function, arguments, result}]` per call. No schema change needed.
|
||||
|
||||
### 2. `read_article` tool
|
||||
**Files:** `src/fabledassistant/services/research.py`, `src/fabledassistant/services/tools.py`, `src/fabledassistant/services/rss.py`
|
||||
|
||||
Move `_fetch_full_article` from `rss.py` to `research.py` (imported back into `rss.py` to avoid breaking existing calls). This makes it available to `execute_tool` without a circular import.
|
||||
|
||||
Tool definition added to `_TOOLS` in `tools.py`:
|
||||
|
||||
```python
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_article",
|
||||
"description": (
|
||||
"Fetch and read the full text of a web page or article from a URL. "
|
||||
"Use when the user shares a URL and wants you to read it, "
|
||||
"or to get the full content of a linked page. "
|
||||
"Do not use search_web for URLs — use this tool instead."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "The URL to fetch"}
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`execute_tool` handler:
|
||||
|
||||
```python
|
||||
elif tool_name == "read_article":
|
||||
from fabledassistant.services.research import _fetch_full_article
|
||||
url = arguments.get("url", "").strip()
|
||||
if not url:
|
||||
return {"success": False, "error": "No URL provided"}
|
||||
content = await _fetch_full_article(url)
|
||||
if not content:
|
||||
return {"success": False, "error": f"Could not fetch article content from {url}"}
|
||||
TOOL_CONTENT_CAP = 40_000
|
||||
truncated = len(content) > TOOL_CONTENT_CAP
|
||||
return {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": url,
|
||||
"content": content[:TOOL_CONTENT_CAP],
|
||||
"truncated": truncated,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `add_message` — add `tool_calls` parameter
|
||||
**File:** `src/fabledassistant/services/chat.py`
|
||||
|
||||
`add_message` needs to accept and store `tool_calls` so the Discuss endpoint can create synthetic messages:
|
||||
|
||||
```python
|
||||
async def add_message(
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
context_note_id: int | None = None,
|
||||
status: str | None = None,
|
||||
tool_calls: list | None = None,
|
||||
) -> Message:
|
||||
```
|
||||
|
||||
Set `msg.tool_calls = tool_calls` when provided.
|
||||
|
||||
### 4. Discuss endpoint
|
||||
**File:** `src/fabledassistant/routes/briefing.py`
|
||||
|
||||
New route: `POST /api/briefing/articles/<int:item_id>/discuss`
|
||||
|
||||
Request body: `{"conv_id": <int>}`
|
||||
|
||||
Steps:
|
||||
1. Look up `rss_items` row by `item_id` — verify it belongs to the user via feed ownership. Return 404 if not found.
|
||||
2. Look up conversation by `conv_id` — verify it belongs to the user. Return 404 if not found.
|
||||
3. If generation already running for `conv_id` → return 409.
|
||||
4. Fetch stored content: `article_content = item.content or item.snippet or ""`
|
||||
5. Store synthetic assistant message (status=`"complete"`, role=`"assistant"`, content=`""`, tool_calls as below):
|
||||
```python
|
||||
synthetic_tool_calls = [{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}]
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
```
|
||||
6. Store user message: `await add_message(conv_id, "user", "Please summarize and discuss this article.")`
|
||||
7. Build `history` from `conv.messages` (using the fixed builder above).
|
||||
8. Create assistant placeholder, create buffer, launch `run_generation` as normal.
|
||||
9. Return `{"assistant_message_id": ..., "status": "generating"}` 202.
|
||||
|
||||
### 5. Frontend: BriefingView.vue
|
||||
**File:** `frontend/src/views/BriefingView.vue`
|
||||
|
||||
Replace `discussArticle()`:
|
||||
|
||||
```typescript
|
||||
async function discussArticle(item: NewsItem) {
|
||||
if (!todayConvId.value) return
|
||||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
||||
await nextTick(() => {
|
||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
await apiClient.post(`/api/briefing/articles/${item.id}/discuss`, {
|
||||
conv_id: todayConvId.value,
|
||||
})
|
||||
// Re-fetch conversation so the new messages appear, then start SSE streaming.
|
||||
// The existing chatStore.fetchConversation + startStreaming pattern handles this.
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
chatStore.startStreaming(todayConvId.value)
|
||||
}
|
||||
```
|
||||
|
||||
The exact method names (`fetchConversation`, `startStreaming`) should match what `BriefingView.vue` already uses for the reply flow — confirm during implementation.
|
||||
|
||||
The article no longer appears as wall-of-text in the user bubble. The chat UI shows it as a `read_article` tool call card (already handled by `ToolCallCard.vue`).
|
||||
|
||||
### 6. Content cap removal
|
||||
**File:** `src/fabledassistant/services/rss.py`
|
||||
|
||||
Remove `[:CONTENT_MAX_CHARS]` from:
|
||||
- `content = _html_to_text(content)[:CONTENT_MAX_CHARS]` in `extract_item()`
|
||||
- `item.content = full_text[:CONTENT_MAX_CHARS]` in the enrichment task
|
||||
|
||||
The `CONTENT_MAX_CHARS` constant can be removed entirely. Trafilatura extracts only article body text (typically 2K–15K chars for news articles), so content is naturally bounded.
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
### User pastes a URL in chat
|
||||
1. User sends message with a URL
|
||||
2. LLM calls `read_article(url)`
|
||||
3. `execute_tool` calls `_fetch_full_article(url)` → trafilatura extracts clean text
|
||||
4. Tool result appended in-memory as `{role: "tool", content: json}`
|
||||
5. LLM responds based on article content
|
||||
6. Generation saves assistant message with `tool_calls=[{function:"read_article", arguments, result}]`
|
||||
7. Follow-up turns: history builder replays tool_call + tool result → article stays in context
|
||||
|
||||
### User clicks Discuss on a briefing article
|
||||
1. Frontend calls `POST /api/briefing/articles/{item_id}/discuss` with `{conv_id}`
|
||||
2. Backend fetches stored article text from DB (no network request)
|
||||
3. Backend stores synthetic assistant message with `read_article` tool result
|
||||
4. Backend stores user message `"Please summarize and discuss this article."`
|
||||
5. Generation runs — LLM sees pre-loaded article in history
|
||||
6. Follow-ups retain context via fixed history builder
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| `_fetch_full_article` returns `None` (network/extraction failure) | Tool returns `{success: False, error: "Could not fetch article content from [url]"}` — LLM reports conversationally |
|
||||
| Discuss: `item_id` not found or wrong user | 404 |
|
||||
| Discuss: `conv_id` not found or wrong user | 404 |
|
||||
| Discuss: article has no stored content | Falls back to empty string — LLM works with what it has |
|
||||
| Discuss: generation already running | 409 |
|
||||
| Messages with `tool_calls = None` | History builder unchanged — no regression for existing conversations |
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **Unit:** `_fetch_full_article` returns `None` → `read_article` tool result has `success: False`
|
||||
- **Unit:** History builder with a stored message that has `tool_calls` → output includes assistant tool_call dict + a `{role: "tool"}` dict
|
||||
- **Unit:** History builder with messages where `tool_calls = None` → output unchanged from current behaviour
|
||||
- **Integration:** `POST /api/briefing/articles/{item_id}/discuss` → two messages stored (synthetic assistant + user message), generation triggered, returns 202
|
||||
@@ -0,0 +1,162 @@
|
||||
# Research Pipeline — Multi-Note Redesign
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the single monolithic research note with a set of focused, topic-driven notes plus an index note that links them — making research output browsable, TTS-friendly, and well-organized.
|
||||
|
||||
**Architecture:** Two new LLM calls (outline generation + N parallel section syntheses) replace the single large synthesis call. Public API unchanged — callers receive the index note. Fallback to single-note behavior on any outline failure.
|
||||
|
||||
**Tech Stack:** Python/Quart backend, existing `research.py` service, asyncio.gather for parallelism.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The current pipeline synthesizes one note with a minimum of 2500 words and 6 sections. This creates:
|
||||
- Notes too large to read or listen to comfortably
|
||||
- No way to navigate directly to a specific sub-topic
|
||||
- TTS failures on long prose (8000-char route limit, unbounded sentence buffers)
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Flow
|
||||
|
||||
Public signature unchanged:
|
||||
```python
|
||||
async def run_research_pipeline(
|
||||
topic: str,
|
||||
user_id: int,
|
||||
model: str,
|
||||
buf=None,
|
||||
project_id: int | None = None,
|
||||
) -> Note: # returns the index note
|
||||
```
|
||||
|
||||
Execution order:
|
||||
|
||||
```
|
||||
1. Generate sub-queries (unchanged)
|
||||
2. Search + fetch sources (unchanged)
|
||||
3. Generate topic outline (NEW — one LLM call → 3–7 section dicts)
|
||||
4. Synthesize each section note (NEW — parallelized via asyncio.gather)
|
||||
5. Create all section notes in DB (sequential, tagged ["research"], same project_id)
|
||||
6. Create index note (NEW — links all sections)
|
||||
7. Return index note
|
||||
```
|
||||
|
||||
Status messages via `buf.append_event("status", ...)`:
|
||||
- `"Generating outline…"`
|
||||
- `"Writing: [Section Title]…"` (one per section, emitted before synthesis starts)
|
||||
- `"Saving [N] notes…"`
|
||||
|
||||
No note content is streamed into chat. After the tool call resolves, the LLM writes a brief conversational summary citing the index note title and section count.
|
||||
|
||||
---
|
||||
|
||||
## Outline Generation
|
||||
|
||||
New function: `_generate_outline(topic, sources, model) -> list[dict]`
|
||||
|
||||
Sends all fetched sources to the model with a prompt requesting a JSON array:
|
||||
|
||||
```json
|
||||
[
|
||||
{"title": "Quantum Entanglement: Mechanisms", "focus": "How entanglement works at the physical level"},
|
||||
{"title": "Quantum Computing Hardware", "focus": "Ion traps, superconducting qubits, photonic approaches"}
|
||||
]
|
||||
```
|
||||
|
||||
**Prompt requirements:**
|
||||
- Produce 3–7 sections covering distinct aspects of the topic
|
||||
- Titles must work as standalone note titles (no "Overview" or "Introduction" generics)
|
||||
- No overlap between sections
|
||||
- `focus` is one sentence describing what this section should specifically cover
|
||||
|
||||
**Guardrails:**
|
||||
- Fewer than 3 sections parsed → fall back to single-note synthesis
|
||||
- JSON parse failure → fall back to single-note synthesis
|
||||
- More than 8 sections → truncate to 8
|
||||
|
||||
**Model params:** `max_tokens=400, num_ctx=16384` (outline is short)
|
||||
|
||||
---
|
||||
|
||||
## Section Synthesis
|
||||
|
||||
New function: `_synthesize_section(section_title, section_focus, sources, model) -> tuple[str, str]`
|
||||
|
||||
Returns `(title, body_markdown)`.
|
||||
|
||||
All sections receive all fetched sources. The `section_focus` field in the prompt directs the model to draw only what's relevant to that section's scope.
|
||||
|
||||
**Prompt requirements:**
|
||||
- 300–600 words of substantive prose
|
||||
- Do NOT include a `# Title` heading (title is set separately)
|
||||
- End with a brief `## Sources` list of relevant URLs from the provided sources
|
||||
- Focus strictly on `section_focus` — ignore source material outside that scope
|
||||
|
||||
**Model params:** `num_predict=2048, num_ctx=16384` (reduced from 8192 — sufficient for 600 words, prevents rambling)
|
||||
|
||||
**Parallelism:** All section synthesis calls run via `asyncio.gather`. Wall-clock time stays close to a single synthesis call despite producing N notes.
|
||||
|
||||
---
|
||||
|
||||
## Note Creation and Index Note
|
||||
|
||||
**Section notes:**
|
||||
- Tags: `["research"]`
|
||||
- `project_id`: same as passed to pipeline (or None)
|
||||
- Title: from outline `title` field
|
||||
- Created sequentially (avoids DB contention)
|
||||
|
||||
**Index note:**
|
||||
- Tags: `["research", "research-index"]`
|
||||
- `project_id`: same as section notes
|
||||
- Title: `"Research: [topic]"`
|
||||
- Created last (after all section notes exist)
|
||||
|
||||
**Index note body format:**
|
||||
```markdown
|
||||
Research overview for **[topic]** — [YYYY-MM-DD]
|
||||
|
||||
Generated from [N] web sources across [M] sections.
|
||||
|
||||
## Sections
|
||||
|
||||
- **[Section 1 Title]** — [focus sentence]
|
||||
- **[Section 2 Title]** — [focus sentence]
|
||||
...
|
||||
|
||||
*Search for any section title to read it.*
|
||||
```
|
||||
|
||||
The index note is what `run_research_pipeline` returns. The existing `research_topic` tool handler uses `note.id` and `note.title` — both remain valid with the index note.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| Outline generation raises | Fall back to single-note synthesis (current behaviour) |
|
||||
| Outline JSON unparseable | Fall back to single-note synthesis |
|
||||
| Outline returns < 3 sections | Fall back to single-note synthesis |
|
||||
| Outline returns > 8 sections | Truncate to 8, continue |
|
||||
| A section synthesis raises | Log warning, skip that section; continue with remaining |
|
||||
| All section syntheses fail | Fall back to single-note synthesis |
|
||||
| A section note DB save fails | Log warning, skip from index; index note still created |
|
||||
| No sources fetched | Raise `ValueError` as today — unchanged |
|
||||
|
||||
The fallback in every case is the current single-note pipeline. Research never silently produces nothing.
|
||||
|
||||
---
|
||||
|
||||
## What Is NOT Changing
|
||||
|
||||
- Public function signature of `run_research_pipeline`
|
||||
- Sub-query generation (`_generate_sub_queries`)
|
||||
- SearXNG search and URL fetching
|
||||
- `_search_searxng`, `_search_searxng_images`, `fetch_url_content`
|
||||
- The `research_topic` tool definition and handler in `tools.py`
|
||||
- The `quick_capture` research path
|
||||
- Any frontend component
|
||||
@@ -0,0 +1,204 @@
|
||||
# Settings Consistency Pass — Design
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix five interrelated gaps in the settings UI — missing timezone field, SSO-unaware account tab, duplicated work schedule, ignored slot toggles, and timezone changes not propagating to the briefing scheduler.
|
||||
|
||||
**Architecture:** Primarily frontend cleanup with two focused backend hooks: settings PUT route gains a timezone→scheduler bridge; briefing scheduler gains slot-gating and work-day awareness.
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript frontend; Python/Quart backend; APScheduler; `zoneinfo`.
|
||||
|
||||
---
|
||||
|
||||
## Problem summary
|
||||
|
||||
1. **No timezone field** — `user_timezone` is read by the scheduler and the chat pipeline but is never exposed in the UI. The briefing tab displays the browser's detected timezone but never persists it. Scheduler falls back to UTC.
|
||||
|
||||
2. **Account tab ignores SSO** — "Email Address" and "Change Password" sections are shown to SSO users (`has_password = false`) even though they cannot change credentials here.
|
||||
|
||||
3. **Work schedule duplicated** — Profile tab has the canonical work schedule (days + start/end time, stored in `profile.work_schedule`). Briefing tab has a redundant "Office Days" section (`briefing_config.work_days`) that the backend never reads.
|
||||
|
||||
4. **Slot toggles are decorative** — The briefing tab's four slot checkboxes are saved to `briefing_config.slots` but `_add_user_jobs` schedules all four slots unconditionally.
|
||||
|
||||
5. **Timezone setting not propagated** — `PUT /api/settings` saves `user_timezone` to the DB but does not call `update_user_schedule`, so the in-memory scheduler keeps the stale timezone until restart or briefing config re-save.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### 1. General tab — Timezone field
|
||||
|
||||
**File:** `frontend/src/views/SettingsView.vue`
|
||||
|
||||
New section in the General tab (after the Assistant section, before Model Management):
|
||||
|
||||
```html
|
||||
<section class="settings-section full-width">
|
||||
<h2>Timezone</h2>
|
||||
<p class="section-desc">Used to schedule briefings and format times in chat.</p>
|
||||
<div class="field">
|
||||
<label for="user-timezone">Your timezone</label>
|
||||
<div style="display:flex; gap:0.5rem; align-items:center">
|
||||
<input id="user-timezone" v-model="userTimezone" type="text"
|
||||
class="input" placeholder="e.g. America/New_York" />
|
||||
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
|
||||
</div>
|
||||
<p class="field-hint">IANA timezone name (e.g. America/Chicago, Europe/London).</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
|
||||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
- `detectTimezone()` sets `userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone`
|
||||
- `saveTimezone()` calls `PUT /api/settings` with `{ user_timezone: userTimezone }`
|
||||
- Loaded in `onMounted` / general settings load alongside `assistantName`, `defaultModel`
|
||||
|
||||
The briefing tab's "Firing in timezone" hint changes from the live Intl API to reading the stored `user_timezone` value:
|
||||
```
|
||||
Firing in timezone: <strong>{{ userTimezone || 'UTC (not set)' }}</strong>
|
||||
```
|
||||
|
||||
### 2. Account tab — SSO guard
|
||||
|
||||
**File:** `frontend/src/views/SettingsView.vue`
|
||||
|
||||
Wrap the Email and Password sections:
|
||||
|
||||
```html
|
||||
<!-- SSO info banner (shown when no local password) -->
|
||||
<section v-if="!authStore.user?.has_password" class="settings-section">
|
||||
<h2>Account</h2>
|
||||
<p class="section-desc">
|
||||
Your account is managed by an external identity provider.
|
||||
Email and password changes are made through your provider, not here.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Local-auth sections (hidden for SSO) -->
|
||||
<template v-if="authStore.user?.has_password">
|
||||
<section class="settings-section"> <!-- Email Address --> </section>
|
||||
<section class="settings-section"> <!-- Change Password --> </section>
|
||||
</template>
|
||||
|
||||
<!-- Active Sessions — always shown -->
|
||||
<section class="settings-section"> ... </section>
|
||||
```
|
||||
|
||||
No backend change needed — the API already rejects email/password changes for SSO accounts.
|
||||
|
||||
### 3. Briefing tab — Remove Office Days
|
||||
|
||||
**File:** `frontend/src/views/SettingsView.vue`
|
||||
|
||||
Delete the "Office Days" `<section>` (lines ~2068–2082). The `briefing_config.work_days` field can remain in the config object for backwards compatibility but the UI stops writing it.
|
||||
|
||||
The slot toggles section stays — it now actually drives scheduling (see §5).
|
||||
|
||||
### 4. Backend — settings PUT propagates timezone to scheduler
|
||||
|
||||
**File:** `src/fabledassistant/routes/settings.py`
|
||||
|
||||
After `set_settings_batch`, add:
|
||||
|
||||
```python
|
||||
if "user_timezone" in to_save:
|
||||
import json
|
||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||
config_raw = await get_setting(uid, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
except Exception:
|
||||
config = {}
|
||||
if config.get("enabled"):
|
||||
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
|
||||
```
|
||||
|
||||
### 5. Backend — scheduler respects slot toggles and work days
|
||||
|
||||
**File:** `src/fabledassistant/services/briefing_scheduler.py`
|
||||
|
||||
**5a. `_add_user_jobs` — only schedule enabled slots**
|
||||
|
||||
Change signature to accept `config: dict`:
|
||||
|
||||
```python
|
||||
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
||||
enabled_slots = (config or {}).get("slots", {})
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
# Default True for compilation (always run); others respect toggle
|
||||
if slot_name != "compilation" and not enabled_slots.get(slot_name, True):
|
||||
jid = _job_id(user_id, slot_name)
|
||||
if _scheduler and _scheduler.get_job(jid):
|
||||
_scheduler.remove_job(jid)
|
||||
continue
|
||||
_scheduler.add_job(
|
||||
_run_user_slot_sync,
|
||||
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
||||
args=[user_id, slot_name],
|
||||
id=_job_id(user_id, slot_name),
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
```
|
||||
|
||||
Update callers:
|
||||
- `update_user_schedule(user_id, config, tz_override)` → pass `config` to `_add_user_jobs`
|
||||
- `start_briefing_scheduler` startup loop → fetch full config to pass through
|
||||
|
||||
**5b. `_run_slot_for_user` — skip morning on non-work days**
|
||||
|
||||
For the `morning` slot, check today against `profile.work_schedule.days`:
|
||||
|
||||
```python
|
||||
if slot == "morning":
|
||||
from fabledassistant.services.user_profile import get_profile
|
||||
from datetime import datetime
|
||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
|
||||
profile = await get_profile(user_id)
|
||||
work_days = (profile.work_schedule or {}).get("days", ["Mon","Tue","Wed","Thu","Fri"])
|
||||
if today_abbr not in work_days:
|
||||
logger.info("Skipping morning slot for user %d — %s not a work day", user_id, today_abbr)
|
||||
return
|
||||
```
|
||||
|
||||
Note: `get_profile` must be importable from `user_profile.py` — confirm signature during implementation.
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
1. User opens Settings → General tab loads, reads `user_timezone` from `GET /api/settings`, populates the field
|
||||
2. User clicks Detect → browser timezone fills the field
|
||||
3. User clicks Save → `PUT /api/settings {user_timezone: "America/New_York"}` → backend saves and immediately calls `update_user_schedule` if briefing enabled
|
||||
4. Briefing tab "Firing in timezone" now shows stored value instead of live browser API
|
||||
5. Next 8am job: scheduler checks if `morning` is enabled in `briefing_config.slots`, then checks if today is in `profile.work_schedule.days` before running
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| `user_timezone` saved as empty string | `update_user_schedule` called with `tz_override=None` → falls back to `briefing_config.timezone` or UTC |
|
||||
| Invalid IANA string saved | `_resolve_timezone` already falls back to UTC with a warning log |
|
||||
| `profile.work_schedule` is None | `morning` slot defaults to Mon–Fri |
|
||||
| Slot toggles key missing from config | All non-compilation slots default to enabled (`True`) — no regression for existing users |
|
||||
| SSO user visits Account tab | Sees info banner; email/password forms hidden; no API calls attempted |
|
||||
|
||||
---
|
||||
|
||||
## What is NOT changing
|
||||
|
||||
- Profile "Interests" and Briefing "News Preferences" remain separate — they serve different purposes (system-prompt personalisation vs RSS topic filtering)
|
||||
- `briefing_config.work_days` field is not deleted from existing configs — just stops being written by the UI
|
||||
- No migration needed — `profile.work_schedule.days` already exists; scheduler change is additive
|
||||
@@ -0,0 +1,278 @@
|
||||
# Web Voice Overlay Polish — Implementation Spec
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection.
|
||||
|
||||
**Architecture:** A new `useSilenceDetector` composable wraps the Web Audio API `AnalyserNode` and fires a callback when sustained silence is detected. `VoiceOverlay` coordinates `useVoiceRecorder` and `useSilenceDetector`, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds the Space bar handler.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, Web Audio API (`AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables, TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Create | `frontend/src/composables/useSilenceDetector.ts` |
|
||||
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
|
||||
| Modify | `frontend/src/components/VoiceOverlay.vue` |
|
||||
| Modify | `frontend/src/App.vue` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `useSilenceDetector` composable
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useSilenceDetector.ts`
|
||||
|
||||
### Interface
|
||||
|
||||
```ts
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options?: SilenceDetectorOptions): {
|
||||
amplitude: Readonly<Ref<number>> // 0–1, for visualization
|
||||
start(stream: MediaStream, onSilence: () => void): void
|
||||
stop(): void
|
||||
}
|
||||
```
|
||||
|
||||
### Behaviour
|
||||
|
||||
- `start(stream, onSilence)`:
|
||||
1. Creates `AudioContext`
|
||||
2. `createMediaStreamSource(stream)` → connects to `AnalyserNode` (fftSize 256)
|
||||
3. Records `startedAt = Date.now()`
|
||||
4. Starts a `setInterval` at 100ms that:
|
||||
- Calls `analyser.getByteFrequencyData(dataArray)`
|
||||
- Computes RMS amplitude → maps to 0–1 range for `amplitude.value`
|
||||
- Converts to approximate dB: `db = 20 * log10(rms)` (clamp to -100 when rms === 0)
|
||||
- If `db < thresholdDb`: increments `silenceMs += 100`; else resets `silenceMs = 0`
|
||||
- If `silenceMs >= silenceDurationMs` AND `Date.now() - startedAt >= minRecordingMs`: clears interval, fires `onSilence()`
|
||||
- `stop()`: clears interval, closes `AudioContext`, resets `amplitude.value = 0`
|
||||
- Safe to call `stop()` multiple times (guard with null check)
|
||||
- `amplitude` resets to 0 after `stop()`
|
||||
|
||||
### Full implementation
|
||||
|
||||
```ts
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number
|
||||
silenceDurationMs?: number
|
||||
minRecordingMs?: number
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
thresholdDb = -40,
|
||||
silenceDurationMs = 1500,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
const amplitude = ref(0)
|
||||
let audioCtx: AudioContext | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let silenceMs = 0
|
||||
let startedAt = 0
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void) {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
analyser.getByteFrequencyData(data)
|
||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
||||
amplitude.value = rms
|
||||
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
||||
if (db < thresholdDb) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
||||
stop()
|
||||
onSilence()
|
||||
}
|
||||
} else {
|
||||
silenceMs = 0
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close().catch(() => {})
|
||||
audioCtx = null
|
||||
}
|
||||
amplitude.value = 0
|
||||
silenceMs = 0
|
||||
}
|
||||
|
||||
return { amplitude: readonly(amplitude), start, stop }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Write the file exactly as above
|
||||
- [ ] Verify TypeScript compiles: `cd frontend && npx tsc --noEmit`
|
||||
- [ ] Commit: `git add frontend/src/composables/useSilenceDetector.ts && git commit -m "feat: add useSilenceDetector composable"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Expose `stream` from `useVoiceRecorder`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
|
||||
|
||||
Change the `stream` local variable to a `Ref<MediaStream | null>` and export it as readonly.
|
||||
|
||||
- [ ] Change `let stream: MediaStream | null = null` to `const streamRef = ref<MediaStream | null>(null)`
|
||||
- [ ] Replace all `stream` assignments with `streamRef.value`:
|
||||
- `stream = await navigator.mediaDevices.getUserMedia(...)` → `streamRef.value = await ...`
|
||||
- `stream?.getTracks().forEach(...)` → `streamRef.value?.getTracks().forEach(...)`
|
||||
- `stream = null` → `streamRef.value = null`
|
||||
- [ ] Add `stream: readonly(streamRef)` to the return object
|
||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
||||
- [ ] Commit: `git add frontend/src/composables/useVoiceRecorder.ts && git commit -m "feat: expose stream ref from useVoiceRecorder"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire `VoiceOverlay` — silence detection + click-to-toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/VoiceOverlay.vue`
|
||||
|
||||
### Script changes
|
||||
|
||||
- [ ] Import `useSilenceDetector` at the top of `<script setup>`
|
||||
- [ ] Add `const silenceDetector = useSilenceDetector()` after the existing composable instantiations
|
||||
- [ ] In `startPtt()`: after `phase.value = 'recording'`, add:
|
||||
```ts
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
||||
}
|
||||
```
|
||||
- [ ] In `stopPtt()`: add `silenceDetector.stop()` as the first line (before the guard check)
|
||||
- [ ] In `cancelAll()`: add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`
|
||||
|
||||
### Button: click-to-toggle
|
||||
|
||||
Replace the PTT mouse/touch handlers on `.voice-ptt-btn` with click-to-toggle logic:
|
||||
|
||||
- [ ] Remove `@mousedown.prevent="startPtt"` and `@mouseup.prevent="stopPtt"`
|
||||
- [ ] Remove `@touchstart.prevent="startPtt"` and `@touchend.prevent="stopPtt"`
|
||||
- [ ] Replace `@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"` with:
|
||||
```html
|
||||
@click.prevent="onBtnClick"
|
||||
```
|
||||
- [ ] Add `onBtnClick` function in script:
|
||||
```ts
|
||||
function onBtnClick() {
|
||||
if (phase.value === 'error') { phase.value = 'idle'; return }
|
||||
if (phase.value === 'recording') { stopPtt(); return }
|
||||
if (phase.value === 'idle') { startPtt() }
|
||||
}
|
||||
```
|
||||
- [ ] Update `aria-label` and `title` on the button:
|
||||
- `aria-label`: `phase === 'recording' ? 'Click to stop' : 'Click to speak'`
|
||||
- `title`: `phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'`
|
||||
|
||||
### Amplitude visualization during recording
|
||||
|
||||
Inside the button, when `phase === 'recording'`, replace the static stop icon with animated amplitude bars:
|
||||
|
||||
- [ ] Replace the recording SVG block:
|
||||
```html
|
||||
<svg v-else-if="phase === 'recording'" ...>...</svg>
|
||||
```
|
||||
with:
|
||||
```html
|
||||
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
|
||||
<span
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="voice-amp-bar"
|
||||
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
|
||||
></span>
|
||||
</span>
|
||||
```
|
||||
|
||||
### Hint label
|
||||
|
||||
- [ ] Change the idle hint from `Hold <kbd>Space</kbd> or tap` to `Tap or press <kbd>Space</kbd>`
|
||||
|
||||
### CSS for amplitude bars
|
||||
|
||||
- [ ] Add to `<style scoped>`:
|
||||
```css
|
||||
.voice-amp-bars {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
}
|
||||
.voice-amp-bar {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
transform-origin: center;
|
||||
transition: transform 0.08s ease;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
||||
- [ ] Commit: `git add frontend/src/components/VoiceOverlay.vue && git commit -m "feat: click-to-toggle silence detection in VoiceOverlay"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Mount overlay and wire Space bar in `App.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.vue`
|
||||
|
||||
### Mount VoiceOverlay
|
||||
|
||||
- [ ] Add import at top of `<script setup>`:
|
||||
```ts
|
||||
import VoiceOverlay from '@/components/VoiceOverlay.vue'
|
||||
```
|
||||
- [ ] Add `<VoiceOverlay />` inside the `<template v-if="authStore.isAuthenticated">` block, just before `<ToastNotification />`:
|
||||
```html
|
||||
<VoiceOverlay />
|
||||
<ToastNotification />
|
||||
```
|
||||
|
||||
### Space bar handler
|
||||
|
||||
- [ ] In `onGlobalKeydown`, add a `Space` case inside the `switch (e.key)` block (after the existing cases), only fires when `!isInputActive()`:
|
||||
```ts
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
||||
break
|
||||
```
|
||||
|
||||
### Shortcuts panel label
|
||||
|
||||
- [ ] Update the Space shortcut description from `Hold to speak (voice, when enabled)` to `Tap to speak (voice, when enabled)`
|
||||
|
||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
||||
- [ ] Verify full build: `npm run build`
|
||||
- [ ] Commit: `git add frontend/src/App.vue && git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut"`
|
||||
@@ -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,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
|
||||
@@ -36,7 +36,7 @@ class FableClient:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=self._headers,
|
||||
timeout=30.0,
|
||||
timeout=httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=10.0),
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -91,7 +91,9 @@ class FableClient:
|
||||
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
|
||||
"""Yield non-empty lines from a streaming GET response (SSE)."""
|
||||
async with self._http().stream("GET", path, **kwargs) as response:
|
||||
self._raise_for_status(response)
|
||||
if response.is_error:
|
||||
await response.aread()
|
||||
self._raise_for_status(response)
|
||||
async for line in response.aiter_lines():
|
||||
if line:
|
||||
yield line
|
||||
|
||||
@@ -565,7 +565,7 @@ async def fable_get_app_logs(
|
||||
"""Fetch Fable application logs. Requires an admin-scoped API key.
|
||||
|
||||
Args:
|
||||
category: Log category — "error" (default), "audit", or "usage".
|
||||
category: Log category — "error" (default), "audit", "usage", or "generation".
|
||||
limit: Maximum number of log entries to return.
|
||||
search: Optional keyword filter matched against action, endpoint, username, details.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ async def get_app_logs(
|
||||
) -> dict:
|
||||
"""Fetch application logs from Fable. Requires an admin-scoped API key.
|
||||
|
||||
category: "error" | "audit" | "usage" (default: "error")
|
||||
category: "error" | "audit" | "usage" | "generation" (default: "error")
|
||||
"""
|
||||
params: dict = {"category": category, "limit": limit}
|
||||
if search:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""MCP tools for Fable chat — create conversations and stream responses."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
from fable_mcp.client import FableClient, FableAPIError
|
||||
|
||||
|
||||
async def list_conversations(
|
||||
@@ -28,7 +29,12 @@ async def send_message(
|
||||
"""Send a message to Fable and return the full assistant response.
|
||||
|
||||
Creates a new MCP conversation if conversation_id is None.
|
||||
Streams the SSE response and accumulates token chunks into a single string.
|
||||
Posts the user message to start generation, then streams the SSE buffer.
|
||||
|
||||
SSE event format:
|
||||
id: <N>
|
||||
event: <type> # chunk | done | tool_call | status | ...
|
||||
data: <json>
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
@@ -41,32 +47,46 @@ async def send_message(
|
||||
"/api/chat/conversations",
|
||||
json={"conversation_type": "mcp"},
|
||||
)
|
||||
conversation_id = conv["id"]
|
||||
conversation_id = str(conv["id"])
|
||||
|
||||
# Build SSE stream URL with message as query param
|
||||
params: dict[str, Any] = {"message": message}
|
||||
if think:
|
||||
params["think"] = "true"
|
||||
# Start generation
|
||||
await client.post(
|
||||
f"/api/chat/conversations/{conversation_id}/messages",
|
||||
json={"content": message, "think": think},
|
||||
)
|
||||
|
||||
tokens: list[str] = []
|
||||
tool_calls: list[Any] = []
|
||||
|
||||
stream_path = f"/api/chat/conversations/{conversation_id}/stream"
|
||||
async for raw_line in client.stream_get(stream_path, params=params):
|
||||
if not raw_line.startswith("data: "):
|
||||
continue
|
||||
payload_str = raw_line[len("data: "):]
|
||||
stream_path = f"/api/chat/conversations/{conversation_id}/generation/stream"
|
||||
|
||||
# Retry connecting to the stream briefly — the background task may not have
|
||||
# created the generation buffer by the time we issue the GET.
|
||||
for attempt in range(10):
|
||||
try:
|
||||
event = json.loads(payload_str)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
event_type = event.get("type")
|
||||
if event_type == "token":
|
||||
tokens.append(event.get("content", ""))
|
||||
elif event_type == "tool_call":
|
||||
tool_calls.append(event)
|
||||
elif event_type == "done":
|
||||
break
|
||||
event_type: str | None = None
|
||||
async for raw_line in client.stream_get(stream_path):
|
||||
if raw_line.startswith("event: "):
|
||||
event_type = raw_line[len("event: "):].strip()
|
||||
elif raw_line.startswith("data: "):
|
||||
payload_str = raw_line[len("data: "):]
|
||||
try:
|
||||
data = json.loads(payload_str)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event_type == "chunk":
|
||||
tokens.append(data.get("chunk", ""))
|
||||
elif event_type == "tool_call":
|
||||
tool_calls.append(data.get("tool_call", data))
|
||||
elif event_type == "done":
|
||||
break
|
||||
event_type = None # reset after consuming data line
|
||||
break # stream completed successfully
|
||||
except FableAPIError as exc:
|
||||
if exc.status_code == 404 and attempt < 9:
|
||||
await asyncio.sleep(0.3)
|
||||
continue
|
||||
raise
|
||||
|
||||
return {
|
||||
"conversation_id": conversation_id,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.0"
|
||||
version = "0.2.4"
|
||||
description = "MCP server for Fabled Assistant"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
+16
-32
@@ -3,7 +3,6 @@ import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppHeader from "@/components/AppHeader.vue";
|
||||
import ToastNotification from "@/components/ToastNotification.vue";
|
||||
import VoiceOverlay from "@/components/VoiceOverlay.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
@@ -23,8 +22,18 @@ const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||
function startAppServices() {
|
||||
chatStore.startStatusPolling();
|
||||
settingsStore.fetchSettings();
|
||||
settingsStore.checkVoiceStatus();
|
||||
// Sync browser timezone to the server on every login/page load.
|
||||
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
|
||||
// Re-check voice status when the tab becomes visible again (model may
|
||||
// have finished loading while the user was away).
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === "visible" && authStore.isAuthenticated) {
|
||||
settingsStore.checkVoiceStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function stopAppServices() {
|
||||
@@ -48,21 +57,9 @@ function clearPrefix() {
|
||||
prefixTimeout = null;
|
||||
}
|
||||
|
||||
let spaceHeld = false;
|
||||
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (!authStore.isAuthenticated) return;
|
||||
|
||||
// Space — PTT start (only when not typing, no modifiers, no repeat)
|
||||
if (e.key === " " && !isInputActive() && !e.ctrlKey && !e.metaKey && !e.altKey && !e.repeat) {
|
||||
e.preventDefault();
|
||||
if (!spaceHeld) {
|
||||
spaceHeld = true;
|
||||
document.dispatchEvent(new CustomEvent("voice:ptt-toggle"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ? — toggle shortcuts overlay (only when not typing)
|
||||
if (e.key === "?" && !isInputActive() && !e.ctrlKey && !e.metaKey) {
|
||||
toggleShortcuts();
|
||||
@@ -93,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;
|
||||
@@ -135,16 +132,8 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function onGlobalKeyup(e: KeyboardEvent) {
|
||||
if (e.key === " " && spaceHeld) {
|
||||
spaceHeld = false;
|
||||
document.dispatchEvent(new CustomEvent("voice:ptt-toggle"));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", onGlobalKeydown);
|
||||
document.addEventListener("keyup", onGlobalKeyup);
|
||||
await authStore.checkAuth();
|
||||
if (authStore.isAuthenticated) {
|
||||
startAppServices();
|
||||
@@ -170,7 +159,7 @@ watch(
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", onGlobalKeydown);
|
||||
document.removeEventListener("keyup", onGlobalKeyup);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
stopAppServices();
|
||||
});
|
||||
</script>
|
||||
@@ -186,9 +175,6 @@ onUnmounted(() => {
|
||||
<footer class="app-footer">v{{ appVersion }}</footer>
|
||||
</div>
|
||||
|
||||
<!-- Global voice PTT overlay -->
|
||||
<VoiceOverlay />
|
||||
|
||||
<!-- Keyboard shortcuts overlay -->
|
||||
<Transition name="shortcuts-fade">
|
||||
<div v-if="showShortcuts" class="shortcuts-overlay" @click.self="closeShortcuts">
|
||||
@@ -216,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>
|
||||
@@ -293,10 +279,6 @@ onUnmounted(() => {
|
||||
<kbd class="shortcut-key">Enter</kbd>
|
||||
<span class="shortcut-desc">New line</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">Space</kbd>
|
||||
<span class="shortcut-desc">Hold to speak (voice, when enabled)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,7 +324,9 @@ onUnmounted(() => {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.app-content:has(.workspace-root) {
|
||||
.app-content:has(.workspace-root),
|
||||
.app-content:has(.chat-page),
|
||||
.app-content:has(.knowledge-root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -426,6 +426,10 @@ export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
||||
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
||||
}
|
||||
|
||||
export async function openArticleInChat(itemId: number): Promise<{ conversation_id: number }> {
|
||||
return apiPost(`/api/chat/from-article/${itemId}`, {});
|
||||
}
|
||||
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||
try {
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
|
||||
@@ -635,14 +639,20 @@ export interface VoiceEntry {
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface VoiceBlendEntry {
|
||||
voice: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export const getVoiceStatus = () => apiGet<VoiceStatusResult>('/api/voice/status')
|
||||
|
||||
export const getVoiceList = () =>
|
||||
apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices)
|
||||
|
||||
export async function transcribeAudio(blob: Blob): Promise<{ transcript: string; duration_ms: number }> {
|
||||
export async function transcribeAudio(blob: Blob, context?: string): Promise<{ transcript: string; duration_ms: number }> {
|
||||
const form = new FormData()
|
||||
form.append('audio', blob, 'audio.webm')
|
||||
if (context) form.append('context', context)
|
||||
const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||
@@ -654,12 +664,23 @@ export async function transcribeAudio(blob: Blob): Promise<{ transcript: string;
|
||||
export async function synthesiseSpeech(
|
||||
text: string,
|
||||
voice?: string,
|
||||
speed?: number
|
||||
speed?: number,
|
||||
voiceBlend?: VoiceBlendEntry[]
|
||||
): Promise<Blob> {
|
||||
// Only send voice/speed/blend when explicitly provided — omitting them lets
|
||||
// the server auto-load the user's saved voice settings (voice, speed, blend).
|
||||
const body: Record<string, unknown> = { text }
|
||||
if (voiceBlend && voiceBlend.length >= 2) {
|
||||
body.voice_blend = voiceBlend
|
||||
if (speed !== undefined) body.speed = speed
|
||||
} else if (voice !== undefined || speed !== undefined) {
|
||||
body.voice = voice ?? 'af_heart'
|
||||
body.speed = speed ?? 1.0
|
||||
}
|
||||
const res = await fetch('/api/voice/synthesise', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, voice: voice ?? 'af_heart', speed: speed ?? 1.0 }),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||
@@ -667,3 +688,26 @@ export async function synthesiseSpeech(
|
||||
}
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
// ── User Profile ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserProfile {
|
||||
display_name: string
|
||||
job_title: string
|
||||
industry: string
|
||||
expertise_level: 'novice' | 'intermediate' | 'expert'
|
||||
response_style: 'concise' | 'balanced' | 'detailed'
|
||||
tone: 'casual' | 'professional' | 'technical'
|
||||
interests: string[]
|
||||
work_schedule: { days?: string[]; start?: string; end?: string }
|
||||
learned_summary: string
|
||||
observations_count: number
|
||||
observations_updated_at: string | null
|
||||
}
|
||||
|
||||
export const getProfile = () => apiGet<UserProfile>('/api/profile')
|
||||
export const updateProfile = (data: Partial<UserProfile>) =>
|
||||
apiPut<UserProfile>('/api/profile', data)
|
||||
export const consolidateProfile = () =>
|
||||
apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {})
|
||||
export const clearProfileObservations = () => apiDelete('/api/profile/observations')
|
||||
|
||||
@@ -180,6 +180,34 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Interactive checkboxes — marked output in the list-note viewer */
|
||||
.prose--checklist ul {
|
||||
list-style: none;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
.prose--checklist li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.prose--checklist li input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
margin: 0;
|
||||
}
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
|
||||
text-decoration: none; /* don't strike through the checkbox itself */
|
||||
}
|
||||
|
||||
/* Tiptap editor */
|
||||
.tiptap-editor .ProseMirror {
|
||||
outline: none;
|
||||
|
||||
@@ -4,22 +4,23 @@
|
||||
--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: #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;
|
||||
@@ -43,33 +44,42 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #111113;
|
||||
--color-bg-secondary: #18181f;
|
||||
--color-bg-card: #1e1e27;
|
||||
--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.12);
|
||||
--color-input-border: rgba(124, 58, 237, 0.22);
|
||||
--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;
|
||||
@@ -82,19 +92,23 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -162,11 +176,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) */
|
||||
|
||||
@@ -15,7 +15,8 @@ const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const isChatActive = computed(() => route.path.startsWith("/chat"));
|
||||
const isChatActive = computed(() => route.path.startsWith("/chat"))
|
||||
const isKnowledgeActive = computed(() => route.path === "/" || route.path === "/knowledge");
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
|
||||
@@ -67,20 +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="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</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 -->
|
||||
@@ -123,13 +123,11 @@ router.afterEach(() => {
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
@@ -154,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.08);
|
||||
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: rgba(124, 58, 237, 0.06);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Right */
|
||||
@@ -199,22 +208,22 @@ router.afterEach(() => {
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-muted);
|
||||
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-secondary);
|
||||
background: rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary);
|
||||
color: #c4b5fd;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
border-radius: 0;
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
box-shadow: 0 0 12px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -236,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); }
|
||||
@@ -245,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 {
|
||||
@@ -371,6 +384,11 @@ router.afterEach(() => {
|
||||
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;
|
||||
}
|
||||
.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 {
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { apiGet, transcribeAudio } from '@/api/client'
|
||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
||||
import { useSilenceDetector } from '@/composables/useSilenceDetector'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { Note } from '@/types/note'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Textarea placeholder */
|
||||
placeholder?: string
|
||||
/** When true, hides the note picker (briefing mode) */
|
||||
briefingMode?: boolean
|
||||
/** Pill shape — compact rounded style for widget */
|
||||
pill?: boolean
|
||||
}>(), {
|
||||
placeholder: 'Type a message… (Enter to send, Shift+Enter for new line)',
|
||||
briefingMode: false,
|
||||
pill: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { content: string; contextNoteId?: number }]
|
||||
abort: []
|
||||
}>()
|
||||
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
|
||||
|
||||
// ── Core input ────────────────────────────────────────────────────────────────
|
||||
const messageInput = ref('')
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||||
const wrapperEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function autoResize() {
|
||||
const el = inputEl.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
|
||||
}
|
||||
|
||||
function resetTextareaHeight() {
|
||||
const el = inputEl.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
const content = messageInput.value.trim()
|
||||
if (!content) return
|
||||
emit('submit', { content, contextNoteId: attachedNote.value?.id })
|
||||
messageInput.value = ''
|
||||
attachedNote.value = null
|
||||
resetTextareaHeight()
|
||||
}
|
||||
|
||||
// ── Note picker ───────────────────────────────────────────────────────────────
|
||||
const attachedNote = ref<{ id: number; title: string } | null>(null)
|
||||
const showNotePicker = ref(false)
|
||||
const noteSearchQuery = ref('')
|
||||
const noteSearchResults = ref<{ id: number; title: string }[]>([])
|
||||
const noteSearchLoading = ref(false)
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function toggleNotePicker() {
|
||||
showNotePicker.value = !showNotePicker.value
|
||||
if (showNotePicker.value) {
|
||||
noteSearchQuery.value = ''
|
||||
noteSearchResults.value = []
|
||||
nextTick(() => {
|
||||
(wrapperEl.value?.querySelector('.note-picker-search') as HTMLInputElement)?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onNoteSearchInput() {
|
||||
if (noteSearchTimer) clearTimeout(noteSearchTimer)
|
||||
noteSearchTimer = setTimeout(async () => {
|
||||
const q = noteSearchQuery.value.trim()
|
||||
if (!q) { noteSearchResults.value = []; return }
|
||||
noteSearchLoading.value = true
|
||||
try {
|
||||
const data = await apiGet<{ notes: Note[] }>(`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`)
|
||||
noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title }))
|
||||
} catch {
|
||||
noteSearchResults.value = []
|
||||
} finally {
|
||||
noteSearchLoading.value = false
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function selectNote(note: { id: number; title: string }) {
|
||||
attachedNote.value = note
|
||||
showNotePicker.value = false
|
||||
}
|
||||
|
||||
function removeAttachedNote() {
|
||||
attachedNote.value = null
|
||||
}
|
||||
|
||||
// ── Voice (click-to-toggle + silence detection) ─────────────────────────────
|
||||
const transcribingVoice = ref(false)
|
||||
const recorder = useVoiceRecorder()
|
||||
const silenceDetector = useSilenceDetector()
|
||||
|
||||
async function toggleVoice() {
|
||||
if (transcribingVoice.value) return
|
||||
if (recorder.recording.value) {
|
||||
await stopRecording()
|
||||
} else {
|
||||
await startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
if (!voiceEnabled.value || recorder.recording.value) return
|
||||
if (!recorder.isSupported) {
|
||||
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
|
||||
return
|
||||
}
|
||||
await recorder.startRecording()
|
||||
if (recorder.error.value) {
|
||||
useToastStore().show(recorder.error.value, 'error')
|
||||
return
|
||||
}
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, () => stopRecording())
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
silenceDetector.stop()
|
||||
if (!recorder.recording.value) return
|
||||
transcribingVoice.value = true
|
||||
try {
|
||||
const blob = await recorder.stopRecording()
|
||||
// Pass last assistant message as context to reduce STT mishearings
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
|
||||
const { transcript } = await transcribeAudio(blob, lastAssistant)
|
||||
if (transcript.trim()) {
|
||||
messageInput.value = transcript.trim()
|
||||
await nextTick()
|
||||
autoResize()
|
||||
onSubmit()
|
||||
}
|
||||
} catch { /* transcription failed silently */ }
|
||||
finally { transcribingVoice.value = false }
|
||||
}
|
||||
|
||||
// ── Exposed interface ─────────────────────────────────────────────────────────
|
||||
function focus() {
|
||||
inputEl.value?.focus()
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
messageInput.value = text
|
||||
nextTick(() => {
|
||||
autoResize()
|
||||
inputEl.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ focus, prefill })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapperEl" class="chat-input-bar" :class="{ 'chat-input-bar--pill': pill }">
|
||||
<!-- Attached note pill -->
|
||||
<div v-if="attachedNote" class="attached-note">
|
||||
<span class="attached-note-pill">
|
||||
{{ attachedNote.title }}
|
||||
<button class="attached-note-remove" aria-label="Remove" @click="removeAttachedNote">×</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<!-- Note picker -->
|
||||
<div class="note-picker-wrapper">
|
||||
<button
|
||||
class="btn-icon"
|
||||
@click="toggleNotePicker"
|
||||
:disabled="!store.chatReady"
|
||||
title="Attach a note"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showNotePicker" class="note-picker-dropdown">
|
||||
<input
|
||||
class="note-picker-search"
|
||||
v-model="noteSearchQuery"
|
||||
@input="onNoteSearchInput"
|
||||
placeholder="Search notes..."
|
||||
/>
|
||||
<div class="note-picker-results">
|
||||
<div
|
||||
v-for="note in noteSearchResults"
|
||||
:key="note.id"
|
||||
class="note-picker-item"
|
||||
@click="selectNote(note)"
|
||||
>{{ note.title || 'Untitled' }}</div>
|
||||
<div v-if="noteSearchLoading" class="note-picker-empty">Searching...</div>
|
||||
<div v-else-if="noteSearchQuery && !noteSearchResults.length" class="note-picker-empty">No notes found</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea -->
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
:placeholder="!store.chatReady ? 'Chat unavailable' : store.streaming ? 'Type to queue… (Enter to queue)' : placeholder"
|
||||
:disabled="!store.chatReady"
|
||||
rows="1"
|
||||
class="input-textarea"
|
||||
></textarea>
|
||||
|
||||
<!-- PTT mic -->
|
||||
<button
|
||||
v-if="voiceEnabled"
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Abort (streaming) or Send -->
|
||||
<button
|
||||
v-if="store.streaming"
|
||||
class="btn-abort-inline"
|
||||
@click="emit('abort')"
|
||||
title="Stop generation"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-send"
|
||||
@click="onSubmit"
|
||||
:disabled="!messageInput.trim() || !store.chatReady"
|
||||
>↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-input-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attached-note {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.attached-note-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.attached-note-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
.attached-note-remove:hover { color: #fff; }
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
background: var(--color-input-bar-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
}
|
||||
|
||||
.chat-input-bar--pill .input-row {
|
||||
border-radius: 24px;
|
||||
padding: 0.6rem 0.6rem 0.6rem 1rem;
|
||||
}
|
||||
|
||||
.input-textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-input-bar-text);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.input-textarea::placeholder { color: var(--color-input-bar-placeholder); }
|
||||
.input-textarea:disabled { opacity: 0.5; }
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-input-bar-text);
|
||||
opacity: 0.6;
|
||||
padding: 0.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover { opacity: 1; }
|
||||
.btn-icon:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.btn-mic.mic-recording { opacity: 1; color: #ef4444; }
|
||||
.btn-mic.mic-transcribing { opacity: 0.5; }
|
||||
|
||||
.note-picker-wrapper { position: relative; }
|
||||
.note-picker-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 260px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
.note-picker-search {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.note-picker-results { max-height: 180px; overflow-y: auto; }
|
||||
.note-picker-item {
|
||||
padding: 0.4rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.note-picker-item:hover { background: var(--color-bg-secondary); }
|
||||
.note-picker-empty { padding: 0.4rem 0.65rem; color: var(--color-text-muted); font-size: 0.8rem; }
|
||||
|
||||
.btn-send {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #7c3aed, #5b21b6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
|
||||
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
|
||||
|
||||
.btn-abort-inline {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import ToolCallCard from '@/components/ToolCallCard.vue'
|
||||
import ToolConfirmCard from '@/components/ToolConfirmCard.vue'
|
||||
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const streamingRendered = computed(() => {
|
||||
if (!store.streamingContent) return ''
|
||||
return renderMarkdown(store.streamingContent)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-message role-assistant">
|
||||
<div class="message-bubble streaming-bubble">
|
||||
<div class="message-header">
|
||||
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
||||
</div>
|
||||
<div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
|
||||
<ToolCallCard
|
||||
v-for="(tc, i) in store.streamingToolCalls"
|
||||
:key="i"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
<ToolConfirmCard
|
||||
v-if="store.streamingPendingTool"
|
||||
:pending-tool="store.streamingPendingTool"
|
||||
@accept="store.confirmTool(true)"
|
||||
@decline="store.confirmTool(false)"
|
||||
/>
|
||||
<div v-if="store.streamingStatus" class="streaming-status-line">
|
||||
<span class="streaming-status-dot"></span>
|
||||
{{ store.streamingStatus }}
|
||||
</div>
|
||||
<details
|
||||
v-if="store.streamingThinking"
|
||||
class="thinking-block"
|
||||
:open="!store.streamingContent"
|
||||
>
|
||||
<summary class="thinking-summary">Reasoning</summary>
|
||||
<pre class="thinking-text">{{ store.streamingThinking }}</pre>
|
||||
</details>
|
||||
<div class="message-content prose" v-html="streamingRendered"></div>
|
||||
<span
|
||||
v-if="!store.streamingStatus && !store.streamingThinking && !store.streamingContent"
|
||||
class="typing-indicator"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,351 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import type { Note } from "@/types/note";
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { content: string; contextNoteId?: number }];
|
||||
}>();
|
||||
|
||||
const store = useChatStore();
|
||||
|
||||
const messageInput = ref("");
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Note picker state
|
||||
const attachedNote = ref<{ id: number; title: string } | null>(null);
|
||||
const showNotePicker = ref(false);
|
||||
const noteSearchQuery = ref("");
|
||||
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||
const noteSearchLoading = ref(false);
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onSubmit() {
|
||||
const content = messageInput.value.trim();
|
||||
if (!content) return;
|
||||
emit("submit", {
|
||||
content,
|
||||
contextNoteId: attachedNote.value?.id,
|
||||
});
|
||||
messageInput.value = "";
|
||||
attachedNote.value = null;
|
||||
resetTextareaHeight();
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
||||
}
|
||||
|
||||
function resetTextareaHeight() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
}
|
||||
|
||||
// Note picker
|
||||
function toggleNotePicker() {
|
||||
showNotePicker.value = !showNotePicker.value;
|
||||
if (showNotePicker.value) {
|
||||
noteSearchQuery.value = "";
|
||||
noteSearchResults.value = [];
|
||||
nextTick(() => {
|
||||
const el = document.querySelector(
|
||||
".dashboard-chat .note-picker-search"
|
||||
) as HTMLInputElement;
|
||||
el?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onNoteSearchInput() {
|
||||
if (noteSearchTimer) clearTimeout(noteSearchTimer);
|
||||
noteSearchTimer = setTimeout(async () => {
|
||||
const q = noteSearchQuery.value.trim();
|
||||
if (!q) {
|
||||
noteSearchResults.value = [];
|
||||
return;
|
||||
}
|
||||
noteSearchLoading.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ notes: Note[] }>(
|
||||
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
|
||||
);
|
||||
noteSearchResults.value = data.notes.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
}));
|
||||
} catch {
|
||||
noteSearchResults.value = [];
|
||||
} finally {
|
||||
noteSearchLoading.value = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function selectNote(note: { id: number; title: string }) {
|
||||
attachedNote.value = note;
|
||||
showNotePicker.value = false;
|
||||
}
|
||||
|
||||
function removeAttachedNote() {
|
||||
attachedNote.value = null;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
inputEl.value?.focus();
|
||||
}
|
||||
|
||||
defineExpose({ focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-chat">
|
||||
<!-- Attached note pill -->
|
||||
<div v-if="attachedNote" class="attached-note">
|
||||
<span class="attached-note-pill">
|
||||
{{ attachedNote.title }}
|
||||
<button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-bar">
|
||||
<div class="note-picker-wrapper">
|
||||
<button
|
||||
class="btn-attach"
|
||||
@click="toggleNotePicker"
|
||||
:disabled="!store.chatReady"
|
||||
title="Attach a note"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="showNotePicker" class="note-picker-dropdown">
|
||||
<input
|
||||
class="note-picker-search"
|
||||
v-model="noteSearchQuery"
|
||||
@input="onNoteSearchInput"
|
||||
placeholder="Search notes..."
|
||||
/>
|
||||
<div class="note-picker-results">
|
||||
<div
|
||||
v-for="note in noteSearchResults"
|
||||
:key="note.id"
|
||||
class="note-picker-item"
|
||||
@click="selectNote(note)"
|
||||
>
|
||||
{{ note.title || "Untitled" }}
|
||||
</div>
|
||||
<div v-if="noteSearchLoading" class="note-picker-empty">
|
||||
Searching...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="noteSearchQuery && !noteSearchResults.length"
|
||||
class="note-picker-empty"
|
||||
>
|
||||
No notes found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
:placeholder="
|
||||
!store.chatReady ? 'Chat unavailable'
|
||||
: store.streaming ? 'Type to queue… (Enter to queue)'
|
||||
: 'Start a new chat… (Enter to send)'
|
||||
"
|
||||
:disabled="!store.chatReady"
|
||||
rows="1"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="onSubmit"
|
||||
:disabled="!messageInput.trim() || !store.chatReady"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-chat {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.attached-note {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.attached-note-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.attached-note-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.attached-note-remove:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
background: var(--color-input-bar-bg);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px var(--color-shadow);
|
||||
}
|
||||
|
||||
.chat-input-bar textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
background: transparent;
|
||||
color: var(--color-input-bar-text);
|
||||
outline: none;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.chat-input-bar textarea::placeholder {
|
||||
color: var(--color-input-bar-placeholder);
|
||||
}
|
||||
.chat-input-bar textarea:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Note picker */
|
||||
.note-picker-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.btn-attach {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-input-bar-text);
|
||||
opacity: 0.6;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-attach:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.btn-attach:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
.note-picker-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 280px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
.note-picker-search {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.note-picker-results {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.note-picker-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.note-picker-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.note-picker-empty {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -270,8 +270,11 @@ async function openEventSlideOver(id: number | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function closeEventSlideOver() {
|
||||
function closeEventSlideOver(changed = false) {
|
||||
eventSlideOverOpen.value = false;
|
||||
if (changed) {
|
||||
document.dispatchEvent(new Event("fable:calendar-changed"));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -522,9 +525,9 @@ function closeEventSlideOver() {
|
||||
:event="eventSlideOverEntry"
|
||||
initial-date=""
|
||||
@close="closeEventSlideOver"
|
||||
@created="closeEventSlideOver"
|
||||
@updated="closeEventSlideOver"
|
||||
@deleted="closeEventSlideOver"
|
||||
@created="() => closeEventSlideOver(true)"
|
||||
@updated="() => closeEventSlideOver(true)"
|
||||
@deleted="() => closeEventSlideOver(true)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* VoiceOverlay — global floating push-to-talk button.
|
||||
*
|
||||
* Full flow: record → transcribe → send to voice conv → stream → TTS → play.
|
||||
* Manages its own "voice" conversation; does NOT touch the chat store so it
|
||||
* never disrupts an open chat session.
|
||||
*
|
||||
* Space bar toggles PTT when no input field is focused (wired from App.vue via
|
||||
* the "voice:ptt-toggle" custom event).
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||||
import { apiPost, apiSSEStream, getVoiceStatus, transcribeAudio, synthesiseSpeech } from '@/api/client'
|
||||
|
||||
// ─── Voice service availability ──────────────────────────────────────────────
|
||||
const voiceEnabled = ref(false)
|
||||
|
||||
async function checkVoice() {
|
||||
try {
|
||||
const s = await getVoiceStatus()
|
||||
voiceEnabled.value = s.enabled && s.stt && s.tts
|
||||
} catch { /* feature absent */ }
|
||||
}
|
||||
|
||||
// ─── Conversation management ─────────────────────────────────────────────────
|
||||
const STORAGE_KEY = 'voice_overlay_conv_id'
|
||||
const convId = ref<number | null>(Number(localStorage.getItem(STORAGE_KEY)) || null)
|
||||
|
||||
interface VoiceMessage { role: 'user' | 'assistant'; content: string }
|
||||
const messages = ref<VoiceMessage[]>([])
|
||||
|
||||
async function ensureConversation(): Promise<number> {
|
||||
if (convId.value) {
|
||||
// Verify it still exists
|
||||
try {
|
||||
await fetch(`/api/chat/conversations/${convId.value}`)
|
||||
.then((r) => { if (!r.ok) throw new Error('gone') })
|
||||
return convId.value
|
||||
} catch {
|
||||
convId.value = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
const conv = await apiPost<{ id: number }>('/api/chat/conversations', {
|
||||
title: 'Voice',
|
||||
conversation_type: 'voice',
|
||||
})
|
||||
convId.value = conv.id
|
||||
localStorage.setItem(STORAGE_KEY, String(conv.id))
|
||||
return conv.id
|
||||
}
|
||||
|
||||
// ─── State machine ────────────────────────────────────────────────────────────
|
||||
type Phase = 'idle' | 'recording' | 'transcribing' | 'generating' | 'speaking' | 'error'
|
||||
const phase = ref<Phase>('idle')
|
||||
const errorMsg = ref('')
|
||||
const streamContent = ref('')
|
||||
const open = ref(false)
|
||||
|
||||
const isBusy = computed(() => phase.value !== 'idle' && phase.value !== 'error')
|
||||
|
||||
// ─── Composables ─────────────────────────────────────────────────────────────
|
||||
const recorder = useVoiceRecorder()
|
||||
const audio = useVoiceAudio()
|
||||
|
||||
// ─── Core PTT flow ────────────────────────────────────────────────────────────
|
||||
async function startPtt() {
|
||||
if (!voiceEnabled.value || isBusy.value) return
|
||||
errorMsg.value = ''
|
||||
open.value = true
|
||||
await recorder.startRecording()
|
||||
if (recorder.error.value) {
|
||||
phase.value = 'error'
|
||||
errorMsg.value = recorder.error.value
|
||||
return
|
||||
}
|
||||
phase.value = 'recording'
|
||||
}
|
||||
|
||||
async function stopPtt() {
|
||||
if (phase.value !== 'recording') return
|
||||
|
||||
phase.value = 'transcribing'
|
||||
let blob: Blob
|
||||
try {
|
||||
blob = await recorder.stopRecording()
|
||||
} catch {
|
||||
phase.value = 'error'
|
||||
errorMsg.value = 'Recording failed'
|
||||
return
|
||||
}
|
||||
|
||||
let transcript: string
|
||||
try {
|
||||
const result = await transcribeAudio(blob)
|
||||
transcript = result.transcript.trim()
|
||||
} catch {
|
||||
phase.value = 'error'
|
||||
errorMsg.value = 'Transcription failed'
|
||||
return
|
||||
}
|
||||
|
||||
if (!transcript) {
|
||||
phase.value = 'idle'
|
||||
return
|
||||
}
|
||||
|
||||
messages.value.push({ role: 'user', content: transcript })
|
||||
scrollToBottom()
|
||||
|
||||
// Send to voice conversation
|
||||
phase.value = 'generating'
|
||||
streamContent.value = ''
|
||||
|
||||
let cid: number
|
||||
try {
|
||||
cid = await ensureConversation()
|
||||
} catch {
|
||||
phase.value = 'error'
|
||||
errorMsg.value = 'Could not create voice conversation'
|
||||
return
|
||||
}
|
||||
|
||||
let assistantMessageId: number
|
||||
try {
|
||||
const resp = await apiPost<{ assistant_message_id: number }>(
|
||||
`/api/chat/conversations/${cid}/messages`,
|
||||
{
|
||||
content: transcript,
|
||||
user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}
|
||||
)
|
||||
assistantMessageId = resp.assistant_message_id
|
||||
} catch {
|
||||
phase.value = 'error'
|
||||
errorMsg.value = 'Failed to send message'
|
||||
return
|
||||
}
|
||||
|
||||
// Stream the response
|
||||
await new Promise<void>((resolve) => {
|
||||
const handle = apiSSEStream(
|
||||
`/api/chat/conversations/${cid}/generation/stream`,
|
||||
(event) => {
|
||||
switch (event.event) {
|
||||
case 'chunk':
|
||||
streamContent.value += event.data.chunk as string
|
||||
break
|
||||
case 'done':
|
||||
handle.close()
|
||||
resolve()
|
||||
break
|
||||
case 'error':
|
||||
handle.close()
|
||||
resolve()
|
||||
break
|
||||
}
|
||||
}
|
||||
)
|
||||
// Safety timeout: 3 minutes
|
||||
setTimeout(() => { handle.close(); resolve() }, 180_000)
|
||||
})
|
||||
|
||||
const responseText = streamContent.value.trim()
|
||||
if (responseText) {
|
||||
messages.value.push({ role: 'assistant', content: responseText })
|
||||
streamContent.value = ''
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
// Synthesise and play
|
||||
if (responseText) {
|
||||
phase.value = 'speaking'
|
||||
try {
|
||||
const wavBlob = await synthesiseSpeech(responseText)
|
||||
await audio.play(wavBlob)
|
||||
} catch {
|
||||
// TTS failure is non-critical; show response text
|
||||
}
|
||||
}
|
||||
|
||||
phase.value = 'idle'
|
||||
assistantMessageId // consumed; suppress lint
|
||||
}
|
||||
|
||||
function cancelAll() {
|
||||
recorder.stopRecording().catch(() => {})
|
||||
audio.stop()
|
||||
phase.value = 'idle'
|
||||
streamContent.value = ''
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
// ─── Space bar PTT (event from App.vue) ──────────────────────────────────────
|
||||
function onPttToggle() {
|
||||
if (!voiceEnabled.value) return
|
||||
if (phase.value === 'recording') {
|
||||
stopPtt()
|
||||
} else if (phase.value === 'idle' || phase.value === 'error') {
|
||||
startPtt()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scroll ───────────────────────────────────────────────────────────────────
|
||||
const transcriptEl = ref<HTMLElement | null>(null)
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
if (transcriptEl.value) {
|
||||
transcriptEl.value.scrollTop = transcriptEl.value.scrollHeight
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
checkVoice()
|
||||
document.addEventListener('voice:ptt-toggle', onPttToggle)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('voice:ptt-toggle', onPttToggle)
|
||||
cancelAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="voiceEnabled" class="voice-overlay">
|
||||
|
||||
<!-- Expanded transcript panel -->
|
||||
<Transition name="panel-slide">
|
||||
<div v-if="open && messages.length > 0" class="voice-panel">
|
||||
<div class="voice-panel-header">
|
||||
<span class="voice-panel-title">Voice</span>
|
||||
<button class="voice-panel-close" @click="open = false" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="voice-transcript" ref="transcriptEl">
|
||||
<div
|
||||
v-for="(msg, i) in messages.slice(-10)"
|
||||
:key="i"
|
||||
:class="['voice-msg', `voice-msg--${msg.role}`]"
|
||||
>{{ msg.content }}</div>
|
||||
<!-- Live streaming text -->
|
||||
<div v-if="phase === 'generating' && streamContent" class="voice-msg voice-msg--assistant voice-msg--streaming">
|
||||
{{ streamContent }}<span class="voice-cursor">▌</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- PTT button -->
|
||||
<div class="voice-btn-wrap">
|
||||
<!-- Status label -->
|
||||
<div v-if="phase !== 'idle'" class="voice-status-label">
|
||||
<span v-if="phase === 'recording'">Recording…</span>
|
||||
<span v-else-if="phase === 'transcribing'">Transcribing…</span>
|
||||
<span v-else-if="phase === 'generating'">Thinking…</span>
|
||||
<span v-else-if="phase === 'speaking'">Speaking…</span>
|
||||
<span v-else-if="phase === 'error'" class="voice-error-label">{{ errorMsg || 'Error' }}</span>
|
||||
</div>
|
||||
<div v-else-if="!open || !messages.length" class="voice-status-label voice-hint">
|
||||
Hold <kbd>Space</kbd> or tap
|
||||
</div>
|
||||
|
||||
<!-- Cancel button (shown while busy or speaking) -->
|
||||
<button
|
||||
v-if="isBusy || audio.playing.value"
|
||||
class="voice-cancel"
|
||||
@click="cancelAll"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Main PTT button -->
|
||||
<button
|
||||
class="voice-ptt-btn"
|
||||
:class="{
|
||||
'voice-ptt--recording': phase === 'recording',
|
||||
'voice-ptt--busy': phase === 'generating' || phase === 'transcribing',
|
||||
'voice-ptt--speaking': phase === 'speaking' || audio.playing.value,
|
||||
'voice-ptt--error': phase === 'error',
|
||||
}"
|
||||
@mousedown.prevent="startPtt"
|
||||
@mouseup.prevent="stopPtt"
|
||||
@touchstart.prevent="startPtt"
|
||||
@touchend.prevent="stopPtt"
|
||||
@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"
|
||||
:disabled="phase === 'transcribing' || phase === 'generating'"
|
||||
:aria-label="phase === 'recording' ? 'Release to send' : 'Hold to speak'"
|
||||
:title="phase === 'recording' ? 'Release to send' : 'Hold Space or tap to speak'"
|
||||
>
|
||||
<!-- Idle: mic icon -->
|
||||
<svg v-if="phase === 'idle' || phase === 'error'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<!-- Recording: waveform / stop icon -->
|
||||
<svg v-else-if="phase === 'recording'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
<!-- Busy: spinner dots -->
|
||||
<span v-else-if="phase === 'transcribing' || phase === 'generating'" class="voice-spinner">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
<!-- Speaking: sound waves -->
|
||||
<svg v-else-if="phase === 'speaking'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-overlay {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 1.5rem;
|
||||
z-index: 8000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── Panel ──────────────────────────────────────────────────────────────── */
|
||||
.voice-panel {
|
||||
pointer-events: all;
|
||||
width: min(320px, 90vw);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.22));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 340px;
|
||||
}
|
||||
|
||||
.voice-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.85rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.voice-panel-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.voice-panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
.voice-panel-close:hover { color: var(--color-text); }
|
||||
|
||||
.voice-transcript {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.65rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.voice-msg {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
max-width: 88%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.voice-msg--user {
|
||||
align-self: flex-end;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.voice-msg--assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.voice-msg--streaming { opacity: 0.85; }
|
||||
.voice-cursor {
|
||||
display: inline-block;
|
||||
animation: blink 0.9s step-end infinite;
|
||||
margin-left: 1px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
|
||||
/* ─── Button cluster ─────────────────────────────────────────────────────── */
|
||||
.voice-btn-wrap {
|
||||
pointer-events: all;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.voice-status-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.18rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px var(--color-shadow, rgba(0,0,0,0.12));
|
||||
}
|
||||
.voice-hint { opacity: 0.7; }
|
||||
.voice-hint kbd {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.05rem 0.25rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.voice-error-label { color: #ef4444; }
|
||||
|
||||
.voice-cancel {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
box-shadow: 0 2px 6px var(--color-shadow, rgba(0,0,0,0.12));
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.voice-cancel:hover { color: #ef4444; border-color: #ef4444; }
|
||||
|
||||
.voice-ptt-btn {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.45);
|
||||
transition: transform 0.12s, box-shadow 0.12s, background 0.2s;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.voice-ptt-btn:hover:not(:disabled) {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.55);
|
||||
}
|
||||
.voice-ptt-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.voice-ptt--recording {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626) !important;
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.5) !important;
|
||||
animation: ptt-pulse 0.9s ease-in-out infinite;
|
||||
}
|
||||
.voice-ptt--busy {
|
||||
background: linear-gradient(135deg, #8b5cf6, #7c3aed) !important;
|
||||
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.45) !important;
|
||||
}
|
||||
.voice-ptt--speaking {
|
||||
background: linear-gradient(135deg, #10b981, #059669) !important;
|
||||
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.45) !important;
|
||||
animation: ptt-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.voice-ptt--error {
|
||||
background: linear-gradient(135deg, #6b7280, #4b5563) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@keyframes ptt-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.08); }
|
||||
}
|
||||
|
||||
/* ─── Spinner dots ───────────────────────────────────────────────────────── */
|
||||
.voice-spinner {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.voice-spinner span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
animation: dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.voice-spinner span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.voice-spinner span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ─── Transition ─────────────────────────────────────────────────────────── */
|
||||
.panel-slide-enter-active,
|
||||
.panel-slide-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.panel-slide-enter-from,
|
||||
.panel-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,9 @@ interface ForecastDay {
|
||||
condition: string
|
||||
high: number
|
||||
low: number
|
||||
precip_probability: number | null
|
||||
precip_mm: number | null
|
||||
windspeed_max: number
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
@@ -17,6 +20,7 @@ interface WeatherData {
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
forecast: ForecastDay[]
|
||||
}
|
||||
|
||||
@@ -25,6 +29,23 @@ const props = defineProps<{
|
||||
tempUnit?: string
|
||||
}>()
|
||||
|
||||
function weatherIcon(condition: string): string {
|
||||
const c = condition.toLowerCase()
|
||||
if (c.includes('thunderstorm')) return '⛈️'
|
||||
if (c.includes('hail')) return '🌨️'
|
||||
if (c.includes('snow showers')) return '🌨️'
|
||||
if (c.includes('snow')) return '❄️'
|
||||
if (c.includes('rain showers: violent')) return '⛈️'
|
||||
if (c.includes('rain showers')) return '🌦️'
|
||||
if (c.includes('drizzle') || c.includes('rain')) return '🌧️'
|
||||
if (c.includes('fog')) return '🌫️'
|
||||
if (c.includes('overcast')) return '☁️'
|
||||
if (c.includes('partly cloudy')) return '⛅'
|
||||
if (c.includes('mainly clear')) return '🌤️'
|
||||
if (c.includes('clear')) return '☀️'
|
||||
return '🌡️'
|
||||
}
|
||||
|
||||
const unit = computed(() => props.tempUnit ?? 'C')
|
||||
|
||||
const tempDelta = computed(() => {
|
||||
@@ -56,6 +77,7 @@ const fetchedAtLabel = computed(() => {
|
||||
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
|
||||
</div>
|
||||
<div class="weather-current">
|
||||
<span class="weather-icon">{{ weatherIcon(weather.condition) }}</span>
|
||||
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
|
||||
<span class="weather-condition">{{ weather.condition }}</span>
|
||||
</div>
|
||||
@@ -66,8 +88,17 @@ const fetchedAtLabel = computed(() => {
|
||||
<div class="weather-forecast" v-if="weather.forecast.length">
|
||||
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
|
||||
<span class="forecast-day-name">{{ day.day }}</span>
|
||||
<span class="forecast-icon">{{ weatherIcon(day.condition) }}</span>
|
||||
<span class="forecast-condition">{{ day.condition }}</span>
|
||||
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
|
||||
<span v-if="day.precip_probability != null && day.precip_probability > 0" class="forecast-precip">
|
||||
💧 {{ day.precip_probability }}%
|
||||
</span>
|
||||
<span v-else-if="day.precip_mm != null && day.precip_mm > 0" class="forecast-precip">
|
||||
💧 {{ day.precip_mm.toFixed(1) }}mm
|
||||
</span>
|
||||
<span v-else class="forecast-precip forecast-precip--dry">💧 —</span>
|
||||
<span class="forecast-wind">💨 {{ day.windspeed_max }} {{ weather.wind_unit ?? 'km/h' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,6 +141,11 @@ const fetchedAtLabel = computed(() => {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weather-temp {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
@@ -153,16 +189,34 @@ const fetchedAtLabel = computed(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forecast-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.forecast-condition {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.forecast-temps {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.forecast-precip,
|
||||
.forecast-wind {
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.forecast-precip--dry {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.weather-unavailable {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
|
||||
@@ -293,6 +293,8 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
|
||||
defineExpose({ reload: loadProjectNotes });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const LISTEN_MODE_KEY = 'fa_listen_mode'
|
||||
|
||||
// Shared across all views — persisted to localStorage
|
||||
const _listenMode = ref<boolean>(localStorage.getItem(LISTEN_MODE_KEY) === 'true')
|
||||
|
||||
watch(_listenMode, (v) => {
|
||||
localStorage.setItem(LISTEN_MODE_KEY, String(v))
|
||||
})
|
||||
|
||||
export function useListenMode() {
|
||||
return _listenMode
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
thresholdDb = -40,
|
||||
silenceDurationMs = 1500,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
const amplitude = ref(0)
|
||||
let audioCtx: AudioContext | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let silenceMs = 0
|
||||
let startedAt = 0
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void): void {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
// Some browsers start AudioContext in "suspended" state — resume so
|
||||
// getByteFrequencyData returns real values instead of all zeros.
|
||||
audioCtx.resume().catch(() => {})
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
analyser.getByteFrequencyData(data)
|
||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
||||
amplitude.value = rms
|
||||
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
||||
if (db < thresholdDb) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
||||
stop()
|
||||
onSilence()
|
||||
}
|
||||
} else {
|
||||
silenceMs = 0
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close().catch(() => {})
|
||||
audioCtx = null
|
||||
}
|
||||
amplitude.value = 0
|
||||
silenceMs = 0
|
||||
}
|
||||
|
||||
return { amplitude: readonly(amplitude), start, stop }
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { synthesiseSpeech } from '@/api/client'
|
||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||||
|
||||
/** Minimum stripped character count to bother synthesizing. */
|
||||
const MIN_CHARS = 3
|
||||
|
||||
/** Matches sentence-terminal punctuation followed by whitespace or end-of-string. */
|
||||
const SENTENCE_BOUNDARY = /[.!?]+(?=\s|$)/
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
|
||||
.replace(/#{1,6}\s+/g, '')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/\n{2,}/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract completed sentences from `text` using SENTENCE_BOUNDARY.
|
||||
* Returns the sentences found and the unconsumed remainder.
|
||||
*/
|
||||
function extractSentences(text: string): { sentences: string[]; remainder: string } {
|
||||
const sentences: string[] = []
|
||||
let remaining = text
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = SENTENCE_BOUNDARY.exec(remaining)) !== null) {
|
||||
const boundary = match.index + match[0].length
|
||||
const sentence = remaining.slice(0, boundary).trim()
|
||||
if (sentence) sentences.push(sentence)
|
||||
remaining = remaining.slice(boundary)
|
||||
}
|
||||
|
||||
return { sentences, remainder: remaining }
|
||||
}
|
||||
|
||||
export interface UseStreamingTtsOptions {
|
||||
streamingContent: Ref<string> | ComputedRef<string>
|
||||
streaming: Ref<boolean> | ComputedRef<boolean>
|
||||
enabled: Ref<boolean> | ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export interface UseStreamingTtsReturn {
|
||||
/** True while any synthesis request is in-flight or audio is playing. */
|
||||
speaking: ComputedRef<boolean>
|
||||
/** Cancel all in-flight synthesis/playback and clear the queue. */
|
||||
stop: () => void
|
||||
/** Speak a complete text through the same sentence-chunking pipeline. */
|
||||
speak: (text: string) => void
|
||||
}
|
||||
|
||||
export function useStreamingTts(options: UseStreamingTtsOptions): UseStreamingTtsReturn {
|
||||
const { streamingContent, streaming, enabled } = options
|
||||
const audio = useVoiceAudio()
|
||||
|
||||
let sentenceBuffer = ''
|
||||
let lastSeenLength = 0
|
||||
let abortId = 0
|
||||
let playQueue: Promise<void> = Promise.resolve()
|
||||
const pendingCount = ref(0)
|
||||
|
||||
const speaking = computed(() => pendingCount.value > 0 || audio.playing.value)
|
||||
|
||||
function stop(): void {
|
||||
abortId++
|
||||
sentenceBuffer = ''
|
||||
lastSeenLength = 0
|
||||
playQueue = Promise.resolve()
|
||||
audio.stop()
|
||||
pendingCount.value = 0
|
||||
}
|
||||
|
||||
async function enqueueSentence(sentence: string, myAbortId: number): Promise<void> {
|
||||
const stripped = stripMarkdown(sentence)
|
||||
if (stripped.length < MIN_CHARS) return
|
||||
|
||||
pendingCount.value++
|
||||
let blob: Blob | null = null
|
||||
try {
|
||||
blob = await synthesiseSpeech(stripped)
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
console.warn('[StreamingTTS] Synthesis failed, retrying', {
|
||||
chars: stripped.length,
|
||||
preview: stripped.slice(0, 80),
|
||||
error: errMsg,
|
||||
})
|
||||
try {
|
||||
blob = await synthesiseSpeech(stripped)
|
||||
} catch (e2) {
|
||||
const errMsg2 = e2 instanceof Error ? e2.message : String(e2)
|
||||
console.warn('[StreamingTTS] Retry failed, sentence dropped', {
|
||||
chars: stripped.length,
|
||||
preview: stripped.slice(0, 80),
|
||||
error: errMsg2,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
pendingCount.value--
|
||||
}
|
||||
|
||||
if (!blob) return
|
||||
|
||||
// Capture blob for the closure — TS can't narrow after async gap
|
||||
const resolvedBlob = blob
|
||||
playQueue = playQueue.then(async () => {
|
||||
if (abortId !== myAbortId) return
|
||||
await audio.play(resolvedBlob)
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchBuffer(flush: boolean): void {
|
||||
if (!enabled.value) return
|
||||
const myAbortId = abortId
|
||||
const { sentences, remainder } = extractSentences(sentenceBuffer)
|
||||
sentenceBuffer = flush ? '' : remainder
|
||||
for (const sentence of sentences) {
|
||||
enqueueSentence(sentence, myAbortId)
|
||||
}
|
||||
if (flush && remainder.trim().length >= MIN_CHARS) {
|
||||
enqueueSentence(remainder.trim(), myAbortId)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch accumulating content — extract new characters since last check
|
||||
watch(streamingContent, (newContent) => {
|
||||
if (!enabled.value) return
|
||||
const delta = newContent.slice(lastSeenLength)
|
||||
lastSeenLength = newContent.length
|
||||
sentenceBuffer += delta
|
||||
dispatchBuffer(false)
|
||||
})
|
||||
|
||||
// Watch streaming flag — stop on new message start, flush on end
|
||||
watch(streaming, (isStreaming) => {
|
||||
if (!enabled.value) return
|
||||
if (isStreaming) {
|
||||
// New message starting — cancel previous response's audio
|
||||
stop()
|
||||
} else {
|
||||
// Stream ended — flush any remaining fragment
|
||||
dispatchBuffer(true)
|
||||
lastSeenLength = 0
|
||||
}
|
||||
})
|
||||
|
||||
function speak(text: string): void {
|
||||
stop()
|
||||
if (!text.trim()) return
|
||||
const myAbortId = abortId
|
||||
const { sentences, remainder } = extractSentences(text)
|
||||
for (const sentence of sentences) {
|
||||
enqueueSentence(sentence, myAbortId)
|
||||
}
|
||||
if (remainder.trim().length >= MIN_CHARS) {
|
||||
enqueueSentence(remainder.trim(), myAbortId)
|
||||
}
|
||||
}
|
||||
|
||||
return { speaking, stop, speak }
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
/**
|
||||
* Audio playback composable wrapping the Web Audio API.
|
||||
*
|
||||
* Usage:
|
||||
* const { playing, isSupported, play, stop } = useVoiceAudio()
|
||||
* await play(wavBlob)
|
||||
*/
|
||||
const VOLUME_KEY = 'fa_voice_volume'
|
||||
|
||||
// Shared volume across all instances — persisted to localStorage per device
|
||||
const _volume = ref<number>(parseFloat(localStorage.getItem(VOLUME_KEY) ?? '1.0'))
|
||||
|
||||
export function useVoiceAudio() {
|
||||
const playing = ref(false)
|
||||
const isSupported = typeof AudioContext !== 'undefined' || typeof (window as unknown as Record<string, unknown>).webkitAudioContext !== 'undefined'
|
||||
@@ -32,15 +30,23 @@ export function useVoiceAudio() {
|
||||
|
||||
const source = ctx.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
source.connect(ctx.destination)
|
||||
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.value = _volume.value
|
||||
source.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
|
||||
currentSource = source
|
||||
playing.value = true
|
||||
|
||||
source.onended = () => {
|
||||
playing.value = false
|
||||
currentSource = null
|
||||
}
|
||||
source.start(0)
|
||||
return new Promise<void>((resolve) => {
|
||||
source.onended = () => {
|
||||
playing.value = false
|
||||
currentSource = null
|
||||
resolve()
|
||||
}
|
||||
source.start(0)
|
||||
})
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
@@ -53,8 +59,14 @@ export function useVoiceAudio() {
|
||||
|
||||
return {
|
||||
playing: readonly(playing),
|
||||
volume: _volume,
|
||||
isSupported,
|
||||
play,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
||||
export function setVoiceVolume(v: number) {
|
||||
_volume.value = Math.max(0, Math.min(1, v))
|
||||
localStorage.setItem(VOLUME_KEY, String(_volume.value))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import { ref, readonly, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Push-to-talk recorder wrapping the browser MediaRecorder API.
|
||||
@@ -15,7 +15,7 @@ export function useVoiceRecorder() {
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let chunks: Blob[] = []
|
||||
let stream: MediaStream | null = null
|
||||
const streamRef = ref<MediaStream | null>(null)
|
||||
let resolveStop: ((blob: Blob) => void) | null = null
|
||||
let rejectStop: ((err: Error) => void) | null = null
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useVoiceRecorder() {
|
||||
if (recording.value) return
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (e) {
|
||||
error.value = 'Microphone access denied'
|
||||
return
|
||||
@@ -41,7 +41,7 @@ export function useVoiceRecorder() {
|
||||
? 'audio/webm'
|
||||
: ''
|
||||
|
||||
mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
|
||||
mediaRecorder = mimeType ? new MediaRecorder(streamRef.value!, { mimeType }) : new MediaRecorder(streamRef.value!)
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunks.push(e.data)
|
||||
@@ -50,8 +50,8 @@ export function useVoiceRecorder() {
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType ?? 'audio/webm' })
|
||||
chunks = []
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
stream = null
|
||||
streamRef.value?.getTracks().forEach((t) => t.stop())
|
||||
streamRef.value = null
|
||||
recording.value = false
|
||||
resolveStop?.(blob)
|
||||
resolveStop = null
|
||||
@@ -88,5 +88,6 @@ export function useVoiceRecorder() {
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
stream: readonly(streamRef) as Readonly<Ref<MediaStream | null>>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HomeView from "@/views/HomeView.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const router = createRouter({
|
||||
@@ -7,8 +6,12 @@ const router = createRouter({
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
name: "knowledge",
|
||||
component: () => import("@/views/KnowledgeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/knowledge",
|
||||
redirect: "/",
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
@@ -42,8 +45,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: "/notes",
|
||||
name: "notes",
|
||||
component: () => import("@/views/NotesListView.vue"),
|
||||
redirect: "/",
|
||||
},
|
||||
{
|
||||
path: "/notes/new",
|
||||
@@ -82,8 +84,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
name: "tasks",
|
||||
component: () => import("@/views/TasksListView.vue"),
|
||||
redirect: "/",
|
||||
},
|
||||
{
|
||||
path: "/tasks/new",
|
||||
|
||||
@@ -80,6 +80,8 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
tags?: string[];
|
||||
project_id?: number | null;
|
||||
milestone_id?: number | null;
|
||||
note_type?: string;
|
||||
metadata?: Record<string, string> | null;
|
||||
}): Promise<Note> {
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
@@ -91,7 +93,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id">>
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type" | "metadata">>
|
||||
): Promise<Note> {
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPut } from "@/api/client";
|
||||
import { apiGet, apiPut, getVoiceStatus } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { AppSettings } from "@/types/settings";
|
||||
|
||||
@@ -16,6 +16,24 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
() => settings.value.default_model || ""
|
||||
);
|
||||
|
||||
// Voice status — checked once on login, refreshable from Settings
|
||||
const voiceEnabled = ref(false);
|
||||
const voiceSttReady = ref(false);
|
||||
const voiceTtsReady = ref(false);
|
||||
|
||||
async function checkVoiceStatus() {
|
||||
try {
|
||||
const s = await getVoiceStatus();
|
||||
voiceEnabled.value = s.enabled;
|
||||
voiceSttReady.value = s.enabled && s.stt;
|
||||
voiceTtsReady.value = s.enabled && s.tts;
|
||||
} catch {
|
||||
voiceEnabled.value = false;
|
||||
voiceSttReady.value = false;
|
||||
voiceTtsReady.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -44,6 +62,10 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
loading,
|
||||
assistantName,
|
||||
defaultModel,
|
||||
voiceEnabled,
|
||||
voiceSttReady,
|
||||
voiceTtsReady,
|
||||
checkVoiceStatus,
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Message {
|
||||
context_note_id: number | null;
|
||||
context_note_title?: string | null;
|
||||
tool_calls?: ToolCallRecord[] | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
timing?: GenerationTiming;
|
||||
thinking?: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface NewsItem {
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
content: string
|
||||
published_at: string | null
|
||||
topics: string[]
|
||||
source: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
export type NoteType = "note" | "person" | "place" | "list";
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
@@ -18,6 +19,8 @@ export interface Note {
|
||||
recurrence_rule: Record<string, unknown> | null;
|
||||
recurrence_next_spawn_at: string | null;
|
||||
is_task: boolean;
|
||||
note_type: NoteType;
|
||||
metadata: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
|
||||
|
||||
function _isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
/** "9:30 AM" */
|
||||
export function fmtTime(dt: string): string {
|
||||
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
|
||||
}
|
||||
|
||||
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
|
||||
export function fmtDateTime(dt: string, allDay: boolean): string {
|
||||
const d = new Date(dt)
|
||||
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
|
||||
if (allDay) return datePart
|
||||
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
|
||||
}
|
||||
|
||||
/**
|
||||
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM"
|
||||
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15"
|
||||
*/
|
||||
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
|
||||
try {
|
||||
const d = new Date(dt)
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(now)
|
||||
tomorrow.setDate(now.getDate() + 1)
|
||||
|
||||
const timeStr = allDay ? "" : ` ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
|
||||
|
||||
if (_isSameDay(d, now)) return `Today${timeStr}`
|
||||
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}`
|
||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15"
|
||||
*/
|
||||
export function fmtDayLabel(dt: string): string {
|
||||
try {
|
||||
const d = new Date(dt)
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(now)
|
||||
tomorrow.setDate(now.getDate() + 1)
|
||||
if (_isSameDay(d, now)) return "Today"
|
||||
if (_isSameDay(d, tomorrow)) return "Tomorrow"
|
||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
}
|
||||
|
||||
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */
|
||||
export function fmtCompact(dt: string, allDay: boolean): string {
|
||||
const d = new Date(dt)
|
||||
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const headingRenderer = {
|
||||
marked.use({ renderer: headingRenderer });
|
||||
|
||||
const PURIFY_OPTS_FULL = {
|
||||
ADD_ATTR: ["data-tag", "data-title"],
|
||||
ADD_ATTR: ["data-tag", "data-title", "data-task-index"],
|
||||
FORCE_BODY: true,
|
||||
};
|
||||
|
||||
@@ -47,10 +47,22 @@ const PURIFY_OPTS_PREVIEW = {
|
||||
FORCE_BODY: true,
|
||||
};
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
export function renderMarkdown(text: string, { interactiveCheckboxes = false } = {}): string {
|
||||
const stripped = stripFirstLineTags(text);
|
||||
const decoded = decodeEntities(stripped);
|
||||
const html = marked(decoded) as string;
|
||||
let html = marked(decoded) as string;
|
||||
|
||||
if (interactiveCheckboxes) {
|
||||
// marked renders task-list checkboxes as <input ... disabled="" ...>.
|
||||
// Remove disabled and stamp a sequential data-task-index so the viewer
|
||||
// can identify which item was toggled regardless of attribute order.
|
||||
html = html.replace(/ disabled=""/g, "");
|
||||
let taskIdx = 0;
|
||||
html = html.replace(/<input ([^>]*type="checkbox"[^>]*)>/g, (_m, attrs) => {
|
||||
return `<input data-task-index="${taskIdx++}" ${attrs}>`;
|
||||
});
|
||||
}
|
||||
|
||||
const withTags = linkifyTags(html);
|
||||
const withLinks = linkifyWikilinks(withTags);
|
||||
const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_FULL);
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import ChatMessage from '@/components/ChatMessage.vue'
|
||||
import ChatPanel from '@/components/ChatPanel.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
getBriefingConfig,
|
||||
getBriefingConversations,
|
||||
getBriefingToday,
|
||||
getBriefingConvMessages,
|
||||
triggerBriefingSlot,
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
getVoiceStatus,
|
||||
transcribeAudio,
|
||||
synthesiseSpeech,
|
||||
type BriefingConversation,
|
||||
type BriefingMessage,
|
||||
} from '@/api/client'
|
||||
import type { Message } from '@/types/chat'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
interface WeatherData {
|
||||
@@ -35,7 +28,8 @@ interface WeatherData {
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
forecast: { day: string; condition: string; high: number; low: number }[]
|
||||
wind_unit?: string
|
||||
forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; precip_mm: number | null; windspeed_max: number }[]
|
||||
}
|
||||
|
||||
const chatStore = useChatStore()
|
||||
@@ -62,10 +56,6 @@ const todayConvId = ref<number | null>(null)
|
||||
|
||||
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||
|
||||
// Messages for selected conversation
|
||||
const messages = ref<BriefingMessage[]>([])
|
||||
const loadingMessages = ref(false)
|
||||
|
||||
// Weather panel (left column)
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
const tempUnit = ref<string>('C')
|
||||
@@ -94,17 +84,6 @@ async function loadNews() {
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// Scroll to bottom of messages
|
||||
const messagesEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesEl.value) {
|
||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [convList, today] = await Promise.all([
|
||||
getBriefingConversations(),
|
||||
@@ -122,62 +101,32 @@ async function loadAll() {
|
||||
]
|
||||
}
|
||||
selectedConvId.value = today.id
|
||||
messages.value = today.messages
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
watch(selectedConvId, async (id) => {
|
||||
if (!id) return
|
||||
if (id === todayConvId.value) {
|
||||
try {
|
||||
await chatStore.fetchConversation(id)
|
||||
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
|
||||
scrollToBottom()
|
||||
} catch {
|
||||
// Historical conversation unavailable — do nothing
|
||||
}
|
||||
})
|
||||
|
||||
async function discussArticle(item: NewsItem) {
|
||||
if (!todayConvId.value || chatStore.streaming) return
|
||||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
||||
await nextTick(() => {
|
||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
try {
|
||||
await apiPost<{ assistant_message_id: number }>(`/api/briefing/articles/${item.id}/discuss`, { conv_id: todayConvId.value })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
messages.value = await getBriefingConvMessages(id)
|
||||
scrollToBottom()
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Refresh messages after streaming ends
|
||||
watch(() => chatStore.streaming, async (streaming) => {
|
||||
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
|
||||
const today = await getBriefingToday().catch(() => null)
|
||||
if (today) messages.value = today.messages
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
// Input
|
||||
const input = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
async function send() {
|
||||
const text = input.value.trim()
|
||||
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
|
||||
if (chatStore.currentConversation?.id !== todayConvId.value) {
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
}
|
||||
input.value = ''
|
||||
sending.value = true
|
||||
try {
|
||||
await chatStore.sendMessage(text)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
await chatStore.reconnectIfGenerating(todayConvId.value)
|
||||
}
|
||||
|
||||
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
|
||||
@@ -213,9 +162,10 @@ async function triggerNow() {
|
||||
triggering.value = true
|
||||
try {
|
||||
await triggerBriefingSlot('compilation')
|
||||
await loadAll()
|
||||
// Guard: user may have navigated away during the long compilation
|
||||
if (_mounted) await loadAll()
|
||||
} finally {
|
||||
triggering.value = false
|
||||
if (_mounted) triggering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,110 +179,13 @@ function convLabel(c: BriefingConversation): string {
|
||||
return c.title || 'Briefing'
|
||||
}
|
||||
|
||||
// ─── Voice ────────────────────────────────────────────────────────────────────
|
||||
const voiceEnabled = ref(false)
|
||||
const listenMode = ref(false)
|
||||
const transcribing = ref(false)
|
||||
const synthesising = ref(false)
|
||||
|
||||
const recorder = useVoiceRecorder()
|
||||
const audio = useVoiceAudio()
|
||||
|
||||
// Check voice availability once on mount
|
||||
async function checkVoice() {
|
||||
try {
|
||||
const status = await getVoiceStatus()
|
||||
voiceEnabled.value = status.enabled && status.stt && status.tts
|
||||
} catch { /* voice feature absent */ }
|
||||
}
|
||||
|
||||
// Read the latest assistant message aloud
|
||||
async function listenToLatest() {
|
||||
const lastAssistant = [...messages.value].reverse().find((m) => m.role === 'assistant')
|
||||
if (!lastAssistant?.content) return
|
||||
await speakText(lastAssistant.content)
|
||||
}
|
||||
|
||||
async function speakText(text: string) {
|
||||
if (!voiceEnabled.value || synthesising.value) return
|
||||
// Strip markdown for cleaner TTS output
|
||||
const plain = text
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
|
||||
.replace(/#{1,6}\s+/g, '')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/\n{2,}/g, ' ')
|
||||
.trim()
|
||||
if (!plain) return
|
||||
synthesising.value = true
|
||||
try {
|
||||
const blob = await synthesiseSpeech(plain)
|
||||
await audio.play(blob)
|
||||
} catch {
|
||||
// TTS failure is non-critical
|
||||
} finally {
|
||||
synthesising.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// PTT: hold to record, release to transcribe and send
|
||||
async function startPtt() {
|
||||
if (!voiceEnabled.value || recorder.recording.value) return
|
||||
await recorder.startRecording()
|
||||
}
|
||||
|
||||
async function stopPtt() {
|
||||
if (!recorder.recording.value) return
|
||||
transcribing.value = true
|
||||
try {
|
||||
const blob = await recorder.stopRecording()
|
||||
const { transcript } = await transcribeAudio(blob)
|
||||
if (transcript.trim()) {
|
||||
input.value = transcript.trim()
|
||||
await send()
|
||||
}
|
||||
} catch {
|
||||
// transcription failure — leave input empty
|
||||
} finally {
|
||||
transcribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-TTS when listen mode is on and streaming ends
|
||||
watch(() => chatStore.streaming, async (streaming) => {
|
||||
if (!streaming && listenMode.value && voiceEnabled.value) {
|
||||
// Small delay to let messages update after stream
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
await listenToLatest()
|
||||
}
|
||||
})
|
||||
|
||||
// Convert BriefingMessage to Message for ChatMessage component
|
||||
function toMsg(m: BriefingMessage): Message {
|
||||
return {
|
||||
id: m.id,
|
||||
conversation_id: -1,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
context_note_id: null,
|
||||
context_note_title: null,
|
||||
created_at: m.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||
async function _backgroundRefreshMessages() {
|
||||
try {
|
||||
const today = await getBriefingToday()
|
||||
if (!today) return
|
||||
const fresh = today.messages
|
||||
const last = fresh[fresh.length - 1]
|
||||
const cur = messages.value[messages.value.length - 1]
|
||||
if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
|
||||
messages.value = fresh
|
||||
if (_mounted && isToday.value && chatStore.currentConversation?.id === todayConvId.value) {
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
await loadNews()
|
||||
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
||||
@@ -344,10 +197,12 @@ useBackgroundRefresh(
|
||||
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
|
||||
)
|
||||
|
||||
let _mounted = true
|
||||
onUnmounted(() => { _mounted = false })
|
||||
|
||||
onMounted(async () => {
|
||||
await checkSetup()
|
||||
if (!showWizard.value) await loadAll()
|
||||
checkVoice()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -368,29 +223,6 @@ onMounted(async () => {
|
||||
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
|
||||
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
|
||||
</select>
|
||||
<!-- Listen button: reads latest assistant message; toggles auto-TTS mode -->
|
||||
<button
|
||||
v-if="voiceEnabled"
|
||||
class="btn-voice-header"
|
||||
:class="{ 'btn-voice-active': listenMode, 'btn-voice-busy': synthesising || audio.playing.value }"
|
||||
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
|
||||
:disabled="synthesising"
|
||||
:title="listenMode ? 'Stop auto-read' : 'Listen to briefing'"
|
||||
>
|
||||
<svg v-if="!synthesising && !audio.playing.value" width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||
</svg>
|
||||
{{ listenMode ? 'Listening' : 'Listen' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="voiceEnabled && (synthesising || audio.playing.value)"
|
||||
class="btn-trigger"
|
||||
@click="audio.stop()"
|
||||
title="Stop playback"
|
||||
>Stop</button>
|
||||
<button
|
||||
class="btn-trigger"
|
||||
@click="triggerNow"
|
||||
@@ -416,79 +248,13 @@ onMounted(async () => {
|
||||
|
||||
<!-- Center column: Chat -->
|
||||
<div class="briefing-center">
|
||||
<div class="briefing-messages-wrap" ref="messagesEl">
|
||||
<div v-if="loadingMessages" class="briefing-loading">Loading…</div>
|
||||
<template v-else>
|
||||
<div v-if="!messages.length" class="briefing-empty">
|
||||
<p>No briefing yet for today.</p>
|
||||
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
|
||||
</div>
|
||||
<div v-else class="briefing-messages">
|
||||
<template v-for="msg in messages" :key="msg.id">
|
||||
<ChatMessage
|
||||
:message="toMsg(msg)"
|
||||
:is-streaming="false"
|
||||
/>
|
||||
</template>
|
||||
<!-- Live streaming bubble for today -->
|
||||
<ChatMessage
|
||||
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
|
||||
:message="{
|
||||
id: -1,
|
||||
conversation_id: todayConvId ?? -1,
|
||||
role: 'assistant',
|
||||
content: chatStore.streamingContent,
|
||||
context_note_id: null,
|
||||
context_note_title: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}"
|
||||
:is-streaming="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Input bar (today only) -->
|
||||
<div v-if="isToday" class="briefing-input-bar">
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="briefing-input"
|
||||
:placeholder="transcribing ? 'Transcribing…' : recorder.recording.value ? 'Recording…' : 'Reply to your briefing…'"
|
||||
rows="1"
|
||||
@keydown="onKeydown"
|
||||
:disabled="transcribing || recorder.recording.value"
|
||||
></textarea>
|
||||
<!-- Mic PTT button (hold to record) -->
|
||||
<button
|
||||
v-if="voiceEnabled && recorder.isSupported"
|
||||
class="btn-mic"
|
||||
:class="{ 'btn-mic-active': recorder.recording.value, 'btn-mic-busy': transcribing }"
|
||||
@mousedown.prevent="startPtt"
|
||||
@mouseup.prevent="stopPtt"
|
||||
@touchstart.prevent="startPtt"
|
||||
@touchend.prevent="stopPtt"
|
||||
:disabled="transcribing || chatStore.streaming || sending"
|
||||
:title="recorder.recording.value ? 'Release to send' : 'Hold to speak'"
|
||||
aria-label="Push to talk"
|
||||
>
|
||||
<svg v-if="!transcribing" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="send"
|
||||
:disabled="!input.trim() || chatStore.streaming || sending"
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ChatPanel
|
||||
variant="full"
|
||||
briefingMode
|
||||
:readOnly="!isToday"
|
||||
placeholder="Reply to your briefing…"
|
||||
class="briefing-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right column: News -->
|
||||
@@ -529,6 +295,12 @@ onMounted(async () => {
|
||||
@click="handleReaction(item.id, 'down')"
|
||||
title="Not interested"
|
||||
>👎</button>
|
||||
<button
|
||||
v-if="isToday && todayConvId"
|
||||
class="reaction-btn discuss-btn"
|
||||
@click="discussArticle(item)"
|
||||
title="Discuss in briefing chat"
|
||||
>💬</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -646,76 +418,11 @@ onMounted(async () => {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-messages-wrap {
|
||||
.briefing-chat-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-loading,
|
||||
.briefing-empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.briefing-empty-hint {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.briefing-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.briefing-input-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.briefing-input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.briefing-input:focus { border-color: var(--color-primary); }
|
||||
|
||||
.btn-send {
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-send:hover:not(:disabled) { opacity: 0.9; }
|
||||
|
||||
/* ─── Right column (News) ────────────────────────────────────────────────── */
|
||||
|
||||
.briefing-right {
|
||||
@@ -837,74 +544,6 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Voice buttons ──────────────────────────────────────────────────────── */
|
||||
|
||||
.btn-voice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-voice-header:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-voice-header:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-voice-active {
|
||||
border-color: var(--color-primary) !important;
|
||||
color: var(--color-primary) !important;
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent) !important;
|
||||
}
|
||||
.btn-voice-busy {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
animation: pulse-border 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.btn-mic {
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.btn-mic:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-mic:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-mic-active {
|
||||
background: color-mix(in srgb, #ef4444 15%, transparent) !important;
|
||||
border-color: #ef4444 !important;
|
||||
color: #ef4444 !important;
|
||||
animation: pulse-border 0.8s ease-in-out infinite;
|
||||
}
|
||||
.btn-mic-busy {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import FullCalendar from "@fullcalendar/vue3";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
@@ -9,12 +9,13 @@ import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction
|
||||
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
|
||||
|
||||
const toast = useToastStore();
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Slide-over state
|
||||
const slideOverEvent = ref<EventEntry | null>(null); // null = create mode
|
||||
const slideOverEvent = ref<EventEntry | null>(null);
|
||||
const slideOverOpen = ref(false);
|
||||
const slideOverDate = ref<string>("");
|
||||
|
||||
@@ -34,15 +35,17 @@ function closeSlideOver() {
|
||||
slideOverOpen.value = false;
|
||||
}
|
||||
|
||||
// Event entry cache keyed by id for quick lookups when clicking FC events
|
||||
// Event entry cache keyed by id
|
||||
const eventCache = new Map<number, EventEntry>();
|
||||
|
||||
function toFcEvent(e: EventEntry) {
|
||||
// For all-day events pass date-only strings so FullCalendar never shifts
|
||||
// the date through timezone conversion (UTC midnight → previous day in UTC-X).
|
||||
return {
|
||||
id: String(e.id),
|
||||
title: e.title,
|
||||
start: e.start_dt,
|
||||
end: e.end_dt ?? undefined,
|
||||
start: e.all_day ? e.start_dt.slice(0, 10) : e.start_dt,
|
||||
end: e.all_day ? (e.end_dt?.slice(0, 10) ?? undefined) : (e.end_dt ?? undefined),
|
||||
allDay: e.all_day,
|
||||
backgroundColor: e.color || undefined,
|
||||
borderColor: e.color || undefined,
|
||||
@@ -50,6 +53,128 @@ function toFcEvent(e: EventEntry) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Upcoming events list ───────────────────────────────────────────────────
|
||||
const upcomingEvents = ref<EventEntry[]>([]);
|
||||
|
||||
async function loadUpcoming() {
|
||||
const now = new Date();
|
||||
const end = new Date(now.getTime() + 28 * 86_400_000); // 4 weeks
|
||||
try {
|
||||
const entries = await listEvents(now.toISOString(), end.toISOString());
|
||||
upcomingEvents.value = entries.sort(
|
||||
(a, b) => new Date(a.start_dt).getTime() - new Date(b.start_dt).getTime()
|
||||
);
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
onMounted(loadUpcoming);
|
||||
|
||||
// ── Month/year picker ──────────────────────────────────────────────────────
|
||||
const currentViewYear = ref(new Date().getFullYear());
|
||||
const currentViewMonth = ref(new Date().getMonth());
|
||||
const pickerOpen = ref(false);
|
||||
const pickerYear = ref(new Date().getFullYear());
|
||||
const pickerStyle = ref<Record<string, string>>({});
|
||||
const pickerEl = ref<HTMLElement | null>(null);
|
||||
|
||||
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
|
||||
|
||||
function handleDatesSet(arg: { view: { currentStart: Date } }) {
|
||||
const d = arg.view.currentStart;
|
||||
currentViewYear.value = d.getFullYear();
|
||||
currentViewMonth.value = d.getMonth();
|
||||
}
|
||||
|
||||
function jumpTo(year: number, month: number) {
|
||||
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
|
||||
pickerOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Event popover ──────────────────────────────────────────────────────────
|
||||
const popover = ref<EventEntry | null>(null);
|
||||
const popoverStyle = ref<Record<string, string>>({});
|
||||
const popoverEl = ref<HTMLElement | null>(null);
|
||||
|
||||
function showPopover(entry: EventEntry, clickEvent: MouseEvent) {
|
||||
popover.value = entry;
|
||||
nextTickPositionPopover(clickEvent);
|
||||
}
|
||||
|
||||
function nextTickPositionPopover(clickEvent: MouseEvent) {
|
||||
// Position after DOM update
|
||||
requestAnimationFrame(() => {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const pw = 280;
|
||||
const ph = 220; // approximate
|
||||
let left = clickEvent.clientX + 8;
|
||||
let top = clickEvent.clientY + 8;
|
||||
if (left + pw > vw - 16) left = clickEvent.clientX - pw - 8;
|
||||
if (top + ph > vh - 16) top = clickEvent.clientY - ph - 8;
|
||||
popoverStyle.value = {
|
||||
position: "fixed",
|
||||
left: `${Math.max(8, left)}px`,
|
||||
top: `${Math.max(8, top)}px`,
|
||||
zIndex: "9999",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
popover.value = null;
|
||||
}
|
||||
|
||||
function onPopoverEdit() {
|
||||
if (popover.value) {
|
||||
openEdit(popover.value);
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
// Close popover / open picker on outside or title click
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
// Title click → toggle month/year picker
|
||||
const titleEl = target.closest(".fc-toolbar-title");
|
||||
if (titleEl) {
|
||||
if (!pickerOpen.value) {
|
||||
pickerYear.value = currentViewYear.value;
|
||||
const rect = titleEl.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
|
||||
pickerStyle.value = {
|
||||
position: "fixed",
|
||||
top: `${rect.bottom + 6}px`,
|
||||
left: `${left}px`,
|
||||
zIndex: "9999",
|
||||
};
|
||||
}
|
||||
pickerOpen.value = !pickerOpen.value;
|
||||
return;
|
||||
}
|
||||
// Close picker on outside click
|
||||
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
|
||||
pickerOpen.value = false;
|
||||
}
|
||||
// Close event popover on outside click
|
||||
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
function onCalendarChanged() {
|
||||
calendarRef.value?.getApi().refetchEvents();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("fable:calendar-changed", onCalendarChanged);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("fable:calendar-changed", onCalendarChanged);
|
||||
});
|
||||
|
||||
// ── Calendar callbacks ─────────────────────────────────────────────────────
|
||||
async function loadEvents(
|
||||
fetchInfo: { startStr: string; endStr: string },
|
||||
successCallback: (events: object[]) => void,
|
||||
@@ -60,19 +185,21 @@ async function loadEvents(
|
||||
eventCache.clear();
|
||||
for (const e of entries) eventCache.set(e.id, e);
|
||||
successCallback(entries.map(toFcEvent));
|
||||
loadUpcoming();
|
||||
} catch (err) {
|
||||
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateClick(arg: DateClickArg) {
|
||||
closePopover();
|
||||
openCreate(arg.dateStr);
|
||||
}
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const entry = eventCache.get(id);
|
||||
if (entry) openEdit(entry);
|
||||
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
|
||||
}
|
||||
|
||||
async function handleEventDrop(arg: EventDropArg) {
|
||||
@@ -82,6 +209,7 @@ async function handleEventDrop(arg: EventDropArg) {
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
||||
eventCache.set(id, updated);
|
||||
loadUpcoming();
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to move event", "error");
|
||||
@@ -95,6 +223,7 @@ async function handleEventResize(arg: EventResizeDoneArg) {
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt });
|
||||
eventCache.set(id, updated);
|
||||
loadUpcoming();
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to resize event", "error");
|
||||
@@ -105,11 +234,11 @@ function onCreated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
||||
closeSlideOver();
|
||||
loadUpcoming();
|
||||
}
|
||||
|
||||
function onUpdated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
// Replace the event in FullCalendar
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) {
|
||||
const existing = api.getEventById(String(entry.id));
|
||||
@@ -119,15 +248,15 @@ function onUpdated(entry: EventEntry) {
|
||||
}
|
||||
}
|
||||
closeSlideOver();
|
||||
loadUpcoming();
|
||||
}
|
||||
|
||||
function onDeleted(id: number) {
|
||||
eventCache.delete(id);
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) {
|
||||
api.getEventById(String(id))?.remove();
|
||||
}
|
||||
if (api) api.getEventById(String(id))?.remove();
|
||||
closeSlideOver();
|
||||
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
|
||||
}
|
||||
|
||||
const calendarOptions: CalendarOptions = {
|
||||
@@ -142,12 +271,28 @@ const calendarOptions: CalendarOptions = {
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
},
|
||||
events: loadEvents,
|
||||
datesSet: handleDatesSet,
|
||||
dateClick: handleDateClick,
|
||||
eventClick: handleEventClick,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResize: handleEventResize,
|
||||
height: "auto",
|
||||
};
|
||||
|
||||
// Group upcoming events by day label
|
||||
const upcomingGrouped = computed(() => {
|
||||
const groups: { label: string; date: string; events: EventEntry[] }[] = [];
|
||||
for (const e of upcomingEvents.value) {
|
||||
const label = fmtDayLabel(e.start_dt);
|
||||
const existing = groups.find((g) => g.label === label);
|
||||
if (existing) {
|
||||
existing.events.push(e);
|
||||
} else {
|
||||
groups.push({ label, date: e.start_dt, events: [e] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,6 +308,99 @@ const calendarOptions: CalendarOptions = {
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||
</div>
|
||||
|
||||
<!-- ── Upcoming events strip ───────────────────────────────────────── -->
|
||||
<div v-if="upcomingEvents.length" class="upcoming-section">
|
||||
<h2 class="upcoming-title">Upcoming</h2>
|
||||
<div class="upcoming-groups">
|
||||
<div v-for="group in upcomingGrouped" :key="group.label" class="upcoming-group">
|
||||
<div class="upcoming-day-label">{{ group.label }}</div>
|
||||
<div class="upcoming-cards">
|
||||
<div
|
||||
v-for="ev in group.events"
|
||||
:key="ev.id"
|
||||
class="upcoming-card"
|
||||
:style="ev.color ? { '--ev-color': ev.color } : {}"
|
||||
@click="openEdit(ev)"
|
||||
>
|
||||
<div class="upcoming-card-accent"></div>
|
||||
<div class="upcoming-card-body">
|
||||
<div class="upcoming-card-title">{{ ev.title }}</div>
|
||||
<div class="upcoming-card-time">
|
||||
<template v-if="ev.all_day">All day</template>
|
||||
<template v-else>
|
||||
{{ fmtTime(ev.start_dt) }}
|
||||
<span v-if="ev.end_dt"> – {{ fmtTime(ev.end_dt) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="ev.location" class="upcoming-card-meta">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||
</svg>
|
||||
{{ ev.location }}
|
||||
</div>
|
||||
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Event popover ───────────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="popover"
|
||||
ref="popoverEl"
|
||||
class="event-popover"
|
||||
:style="popoverStyle"
|
||||
>
|
||||
<div class="popover-accent" :style="popover.color ? { background: popover.color } : {}"></div>
|
||||
<div class="popover-content">
|
||||
<div class="popover-title">{{ popover.title }}</div>
|
||||
<div class="popover-time">
|
||||
<template v-if="popover.all_day">
|
||||
{{ fmtDateTime(popover.start_dt, true) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ fmtDateTime(popover.start_dt, false) }}
|
||||
<span v-if="popover.end_dt"> – {{ fmtTime(popover.end_dt) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="popover.location" class="popover-meta">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;margin-top:1px">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||
</svg>
|
||||
{{ popover.location }}
|
||||
</div>
|
||||
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
|
||||
<div class="popover-actions">
|
||||
<button class="popover-btn popover-btn--edit" @click="onPopoverEdit">Edit</button>
|
||||
<button class="popover-btn popover-btn--close" @click="closePopover">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Month/year picker ──────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
|
||||
<div class="picker-year-row">
|
||||
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year">‹</button>
|
||||
<span class="picker-year-label">{{ pickerYear }}</span>
|
||||
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year">›</button>
|
||||
</div>
|
||||
<div class="picker-months">
|
||||
<button
|
||||
v-for="(name, i) in MONTH_NAMES"
|
||||
:key="i"
|
||||
class="picker-month"
|
||||
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
|
||||
@click="jumpTo(pickerYear, i)"
|
||||
>{{ name }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<EventSlideOver
|
||||
v-if="slideOverOpen"
|
||||
:event="slideOverEvent"
|
||||
@@ -177,7 +415,7 @@ const calendarOptions: CalendarOptions = {
|
||||
|
||||
<style scoped>
|
||||
.calendar-view {
|
||||
max-width: 1200px;
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.5rem 3rem;
|
||||
}
|
||||
@@ -210,7 +448,7 @@ const calendarOptions: CalendarOptions = {
|
||||
.btn-new-event:hover { opacity: 0.88; }
|
||||
|
||||
.fc-wrapper {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
padding: 1rem;
|
||||
@@ -225,9 +463,17 @@ const calendarOptions: CalendarOptions = {
|
||||
:deep(.fc-toolbar-title) {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
transition: background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
:deep(.fc-toolbar-title:hover) {
|
||||
background: rgba(255,255,255,0.07);
|
||||
}
|
||||
:deep(.fc-button) {
|
||||
background: var(--color-input-bg, #111113);
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.82rem;
|
||||
@@ -267,4 +513,249 @@ const calendarOptions: CalendarOptions = {
|
||||
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
||||
:deep(.fc-daygrid-day) { cursor: pointer; }
|
||||
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
||||
|
||||
/* ── Month/year picker ──────────────────────────────────────────────────── */
|
||||
.month-picker {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
width: 280px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.picker-year-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.picker-year-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.picker-year-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 2px 10px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.picker-year-btn:hover {
|
||||
color: var(--color-text, #e8e9f0);
|
||||
border-color: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.picker-months {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.picker-month {
|
||||
padding: 7px 4px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
text-align: center;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.picker-month:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.picker-month.active {
|
||||
background: rgba(99,102,241,0.22);
|
||||
color: var(--color-primary, #818cf8);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
||||
.upcoming-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.upcoming-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.78rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.upcoming-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.upcoming-day-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upcoming-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.upcoming-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.upcoming-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.upcoming-card-accent {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--ev-color, #6366f1);
|
||||
}
|
||||
|
||||
.upcoming-card-body {
|
||||
padding: 0.6rem 0.85rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upcoming-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.upcoming-card-time {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.upcoming-card-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.upcoming-card-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
margin-top: 0.3rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Event popover ──────────────────────────────────────────────────────── */
|
||||
.event-popover {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||||
width: 280px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popover-accent {
|
||||
height: 4px;
|
||||
background: var(--color-primary, #6366f1);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.popover-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.popover-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.popover-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.popover-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.6rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popover-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2b30);
|
||||
}
|
||||
|
||||
.popover-btn {
|
||||
flex: 1;
|
||||
padding: 0.35rem 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.popover-btn:hover { opacity: 0.85; }
|
||||
.popover-btn--edit {
|
||||
background: var(--color-primary, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
.popover-btn--close {
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
color: var(--color-text-muted, #888);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
}
|
||||
</style>
|
||||
|
||||
+120
-1233
File diff suppressed because it is too large
Load Diff
+22
-195
@@ -1,18 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { apiGet, listEvents } from "@/api/client";
|
||||
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh";
|
||||
import { milestoneColor } from "@/utils/palette";
|
||||
import { fmtRelativeDateTime } from "@/utils/dateFormat";
|
||||
import type { Note } from "@/types/note";
|
||||
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
|
||||
import type { ToolCallRecord, Message } from "@/types/chat";
|
||||
import type { EventEntry } from "@/api/client";
|
||||
import NoteCard from "@/components/NoteCard.vue";
|
||||
import TaskCard from "@/components/TaskCard.vue";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import DashboardChatInput from "@/components/DashboardChatInput.vue";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
@@ -78,9 +77,11 @@ function _dateRange() {
|
||||
const today = new Date()
|
||||
const nextWeek = new Date(today)
|
||||
nextWeek.setDate(today.getDate() + 7)
|
||||
// Use full ISO strings so the server sees the correct UTC equivalent of
|
||||
// local midnight / end-of-day rather than a naive UTC-midnight guess.
|
||||
return {
|
||||
todayStr: today.toISOString().slice(0, 10) + 'T00:00:00',
|
||||
nextWeekStr: nextWeek.toISOString().slice(0, 10) + 'T23:59:59',
|
||||
todayStr: today.toISOString(),
|
||||
nextWeekStr: nextWeek.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,11 +110,7 @@ function _backgroundRefresh() {
|
||||
|
||||
onMounted(async () => {
|
||||
// Phase 1: projects list + cross-project recent items + orphaned items + events — all parallel
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().slice(0, 10) + "T00:00:00";
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(today.getDate() + 7);
|
||||
const nextWeekStr = nextWeek.toISOString().slice(0, 10) + "T23:59:59";
|
||||
const { todayStr, nextWeekStr } = _dateRange();
|
||||
|
||||
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes, eventsRes] =
|
||||
await Promise.allSettled([
|
||||
@@ -156,7 +153,7 @@ onMounted(async () => {
|
||||
loading.value = false;
|
||||
|
||||
// Focus chat input after data loads
|
||||
chatInputRef.value?.focus();
|
||||
chatPanelRef.value?.focus();
|
||||
loadProjects();
|
||||
|
||||
});
|
||||
@@ -215,10 +212,10 @@ function onStatusToggle(id: number, status: TaskStatus) {
|
||||
|
||||
// ─── Chat widget ──────────────────────────────────────────────────────────────
|
||||
|
||||
const chatInputRef = ref<{ focus: () => void } | null>(null);
|
||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
||||
|
||||
function onFocusChatShortcut() {
|
||||
chatInputRef.value?.focus();
|
||||
chatPanelRef.value?.focus();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -234,16 +231,6 @@ chatStore.fetchStatus().then(() => {
|
||||
if (chatStore.defaultModel) chatStore.warmModel(chatStore.defaultModel);
|
||||
});
|
||||
|
||||
const dashboardConvId = ref<number | null>(null);
|
||||
const dashboardDone = ref(false);
|
||||
const dashboardQuery = ref("");
|
||||
const dashboardFinalContent = ref("");
|
||||
const dashboardFinalToolCalls = ref<ToolCallRecord[]>([]);
|
||||
|
||||
const isConversational = computed(
|
||||
() => dashboardDone.value && dashboardFinalToolCalls.value.length === 0
|
||||
);
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
"What's due today?",
|
||||
"Events this week?",
|
||||
@@ -251,38 +238,8 @@ const QUICK_ACTIONS = [
|
||||
"My high priority tasks?",
|
||||
];
|
||||
|
||||
async function onChatSubmit(payload: { content: string; contextNoteId?: number }) {
|
||||
dashboardConvId.value = null;
|
||||
dashboardDone.value = false;
|
||||
dashboardFinalContent.value = "";
|
||||
dashboardFinalToolCalls.value = [];
|
||||
dashboardQuery.value = payload.content;
|
||||
|
||||
const conv = await chatStore.createConversation();
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
dashboardConvId.value = conv.id;
|
||||
|
||||
await chatStore.sendMessage(payload.content, payload.contextNoteId);
|
||||
|
||||
const msgs = chatStore.currentConversation?.messages ?? [];
|
||||
const lastAssistant = [...msgs]
|
||||
.reverse()
|
||||
.find((m: Message) => m.role === "assistant");
|
||||
dashboardFinalContent.value = lastAssistant?.content ?? "";
|
||||
dashboardFinalToolCalls.value = (lastAssistant?.tool_calls ?? []) as ToolCallRecord[];
|
||||
dashboardDone.value = true;
|
||||
}
|
||||
|
||||
async function onQuickAction(query: string) {
|
||||
await onChatSubmit({ content: query });
|
||||
}
|
||||
|
||||
function clearDashboardResponse() {
|
||||
dashboardConvId.value = null;
|
||||
dashboardDone.value = false;
|
||||
dashboardQuery.value = "";
|
||||
dashboardFinalContent.value = "";
|
||||
dashboardFinalToolCalls.value = [];
|
||||
await chatPanelRef.value?.send(query);
|
||||
}
|
||||
|
||||
// ─── Upcoming events slide-over ───────────────────────────────────────────────
|
||||
@@ -304,28 +261,8 @@ function onEventDeleted(id: number) {
|
||||
}
|
||||
|
||||
function formatUpcomingTime(event: EventEntry): string {
|
||||
if (event.all_day) return "All day";
|
||||
if (!event.start_dt) return "";
|
||||
try {
|
||||
const d = new Date(event.start_dt);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const isToday =
|
||||
d.getFullYear() === today.getFullYear() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getDate() === today.getDate();
|
||||
const isTomorrow =
|
||||
d.getFullYear() === tomorrow.getFullYear() &&
|
||||
d.getMonth() === tomorrow.getMonth() &&
|
||||
d.getDate() === tomorrow.getDate();
|
||||
const timeStr = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
||||
if (isToday) return `Today ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow ${timeStr}`;
|
||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + " " + timeStr;
|
||||
} catch {
|
||||
return event.start_dt;
|
||||
}
|
||||
return fmtRelativeDateTime(event.start_dt, event.all_day);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -343,38 +280,9 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
@click="onQuickAction(q)"
|
||||
>{{ q }}</button>
|
||||
</div>
|
||||
<DashboardChatInput ref="chatInputRef" @submit="onChatSubmit" />
|
||||
<ChatPanel ref="chatPanelRef" variant="widget" />
|
||||
</section>
|
||||
|
||||
<!-- ── Inline response ────────────────────────────────────── -->
|
||||
<div v-if="dashboardConvId" class="dashboard-response">
|
||||
<div class="dashboard-response-query">{{ dashboardQuery }}</div>
|
||||
<div v-if="chatStore.streaming && chatStore.streamingToolCalls.length" class="dashboard-tool-calls">
|
||||
<ToolCallCard v-for="(tc, i) in chatStore.streamingToolCalls" :key="i" :tool-call="tc" />
|
||||
</div>
|
||||
<div v-else-if="dashboardDone && dashboardFinalToolCalls.length" class="dashboard-tool-calls">
|
||||
<ToolCallCard v-for="(tc, i) in dashboardFinalToolCalls" :key="i" :tool-call="tc" />
|
||||
</div>
|
||||
<div v-if="chatStore.streaming" class="dashboard-response-text streaming">
|
||||
<div v-if="chatStore.streamingStatus && !chatStore.streamingContent" class="dashboard-status-line">
|
||||
<span class="dashboard-status-dot"></span>{{ chatStore.streamingStatus }}
|
||||
</div>
|
||||
<span v-else-if="chatStore.streamingContent">{{ chatStore.streamingContent }}</span>
|
||||
<span v-else class="thinking-dots">...</span>
|
||||
</div>
|
||||
<div v-else-if="dashboardDone && dashboardFinalContent" class="dashboard-response-text">
|
||||
{{ dashboardFinalContent }}
|
||||
</div>
|
||||
<div class="dashboard-response-actions" :class="{ conversational: isConversational }">
|
||||
<router-link
|
||||
:to="`/chat/${dashboardConvId}`"
|
||||
class="btn-open-chat"
|
||||
:class="{ prominent: isConversational }"
|
||||
>{{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }}</router-link>
|
||||
<button class="btn-clear-response" @click="clearDashboardResponse">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Upcoming events ────────────────────────────────────── -->
|
||||
<div v-if="!loading && upcomingEvents.length" class="upcoming-events-section">
|
||||
<div class="section-header">
|
||||
@@ -593,16 +501,20 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
max-width: 1100px;
|
||||
max-width: var(--page-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
padding: 0 var(--page-padding-x);
|
||||
}
|
||||
|
||||
/* ─── Chat widget ────────────────────────────────────────────── */
|
||||
.chat-section { margin-bottom: 1rem; }
|
||||
.chat-section {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
@@ -623,91 +535,6 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
}
|
||||
.quick-action-chip:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* ─── Inline response ────────────────────────────────────────── */
|
||||
.dashboard-response {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-card);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--color-bubble-asst-shadow);
|
||||
}
|
||||
.dashboard-response-query {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.dashboard-tool-calls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.dashboard-response-text {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.dashboard-response-text.streaming { color: var(--color-text-muted); }
|
||||
.thinking-dots { display: inline-block; animation: blink 1.2s infinite; }
|
||||
.dashboard-status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.dashboard-status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
animation: blink 1.2s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.dashboard-response-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.btn-open-chat {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-open-chat:hover { text-decoration: underline; }
|
||||
.btn-open-chat.prominent {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-open-chat.prominent:hover {
|
||||
text-decoration: none;
|
||||
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn-clear-response {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-clear-response:hover { color: var(--color-text); }
|
||||
|
||||
/* ─── Skeleton loading ───────────────────────────────────────── */
|
||||
.skeleton-hero {
|
||||
height: 180px;
|
||||
@@ -1034,7 +861,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
|
||||
/* ─── Mobile ─────────────────────────────────────────────────── */
|
||||
@media (max-width: 680px) {
|
||||
.home { padding: 0 1rem; margin: 1rem auto; }
|
||||
.home { padding: 0 var(--page-padding-x); margin: 1rem auto; }
|
||||
.hero-top { flex-direction: column; align-items: flex-start; }
|
||||
.btn-workspace-hero { width: 100%; justify-content: center; }
|
||||
.projects-grid { grid-template-columns: 1fr; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getBriefingFeeds,
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
openArticleInChat,
|
||||
type BriefingFeed,
|
||||
} from '@/api/client'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const LIMIT = 40
|
||||
|
||||
const items = ref<NewsItem[]>([])
|
||||
@@ -20,6 +24,8 @@ const selectedFeedId = ref<number | null>(null)
|
||||
|
||||
// Reactions map: item id → current reaction
|
||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
||||
// Track which items are currently being opened in chat
|
||||
const openingChat = ref<Set<number>>(new Set())
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || !hasMore.value) return
|
||||
@@ -79,6 +85,19 @@ function formatRelativeDate(iso: string | null): string {
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
async function openInChat(itemId: number) {
|
||||
if (openingChat.value.has(itemId)) return
|
||||
openingChat.value.add(itemId)
|
||||
try {
|
||||
const result = await openArticleInChat(itemId)
|
||||
router.push(`/chat/${result.conversation_id}`)
|
||||
} catch {
|
||||
// silently fail — button returns to enabled state
|
||||
} finally {
|
||||
openingChat.value.delete(itemId)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
feeds.value = await getBriefingFeeds().catch(() => [])
|
||||
await loadMore()
|
||||
@@ -145,6 +164,13 @@ onMounted(async () => {
|
||||
@click="handleReaction(item.id, 'down')"
|
||||
title="Not interested"
|
||||
>👎</button>
|
||||
<button
|
||||
class="reaction-btn open-chat-btn"
|
||||
:class="{ busy: openingChat.has(item.id) }"
|
||||
:disabled="openingChat.has(item.id)"
|
||||
@click="openInChat(item.id)"
|
||||
title="Discuss in chat"
|
||||
>{{ openingChat.has(item.id) ? '…' : '💬' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -335,6 +361,15 @@ a.news-card-title:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.open-chat-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.open-chat-btn.busy {
|
||||
opacity: 0.4;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.news-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { NoteType } from "@/types/note";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
@@ -31,6 +32,8 @@ const body = ref("");
|
||||
const tags = ref<string[]>([]);
|
||||
const projectId = ref<number | null>(null);
|
||||
const milestoneId = ref<number | null>(null);
|
||||
const noteType = ref<NoteType>("note");
|
||||
const entityMeta = reactive<Record<string, string>>({});
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
@@ -186,6 +189,8 @@ let savedBody = "";
|
||||
let savedTags: string[] = [];
|
||||
let savedProjectId: number | null = null;
|
||||
let savedMilestoneId: number | null = null;
|
||||
let savedNoteType: NoteType = "note";
|
||||
let savedEntityMeta: Record<string, string> = {};
|
||||
|
||||
function markDirty() {
|
||||
dirty.value =
|
||||
@@ -193,7 +198,9 @@ function markDirty() {
|
||||
body.value !== savedBody ||
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
projectId.value !== savedProjectId ||
|
||||
milestoneId.value !== savedMilestoneId;
|
||||
milestoneId.value !== savedMilestoneId ||
|
||||
noteType.value !== savedNoteType ||
|
||||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -210,24 +217,34 @@ onMounted(async () => {
|
||||
tags.value = [...(store.currentNote.tags || [])];
|
||||
projectId.value = store.currentNote.project_id ?? null;
|
||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||||
Object.assign(entityMeta, store.currentNote.metadata || {});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedEntityMeta = { ...entityMeta };
|
||||
}
|
||||
|
||||
// Restore pending draft if any
|
||||
try {
|
||||
const draft = await apiGet<NoteDraft>(`/api/notes/${noteId.value}/draft`);
|
||||
if (draft) assist.loadDraft(draft);
|
||||
} catch {
|
||||
// No draft — normal
|
||||
} else {
|
||||
// New note: read type from query param
|
||||
const qt = route.query.type as string | undefined;
|
||||
if (qt && ["note", "person", "place", "list"].includes(qt)) {
|
||||
noteType.value = qt as NoteType;
|
||||
}
|
||||
|
||||
// Initial link suggestions
|
||||
fetchLinkSuggestions();
|
||||
}
|
||||
|
||||
// Restore pending draft if any
|
||||
try {
|
||||
const draft = await apiGet<NoteDraft>(`/api/notes/${noteId.value}/draft`);
|
||||
if (draft) assist.loadDraft(draft);
|
||||
} catch {
|
||||
// No draft — normal
|
||||
}
|
||||
|
||||
// Initial link suggestions
|
||||
fetchLinkSuggestions();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
@@ -241,12 +258,16 @@ async function save() {
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
metadata: { ...entityMeta },
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedEntityMeta = { ...entityMeta };
|
||||
dirty.value = false;
|
||||
toast.show("Note saved");
|
||||
} else {
|
||||
@@ -256,6 +277,8 @@ async function save() {
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
metadata: { ...entityMeta },
|
||||
});
|
||||
dirty.value = false;
|
||||
toast.show("Note created");
|
||||
@@ -295,12 +318,15 @@ async function doAutoSave() {
|
||||
await store.updateNote(noteId.value!, {
|
||||
title: title.value, body: body.value, tags: tags.value,
|
||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
||||
} as Record<string, unknown>);
|
||||
note_type: noteType.value, metadata: { ...entityMeta },
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedEntityMeta = { ...entityMeta };
|
||||
dirty.value = false;
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
@@ -439,6 +465,49 @@ onUnmounted(() => assist.clearSelection());
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Note type -->
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Type</label>
|
||||
<select v-model="noteType" class="sb-select" @change="markDirty">
|
||||
<option value="note">Note</option>
|
||||
<option value="person">Person</option>
|
||||
<option value="place">Place</option>
|
||||
<option value="list">List</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Person metadata -->
|
||||
<template v-if="noteType === 'person'">
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Relationship</label>
|
||||
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Email</label>
|
||||
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Phone</label>
|
||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Place metadata -->
|
||||
<template v-if="noteType === 'place'">
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Address</label>
|
||||
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Phone</label>
|
||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Hours</label>
|
||||
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. 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">
|
||||
@@ -649,6 +718,22 @@ onUnmounted(() => assist.clearSelection());
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sb-select, .sb-input {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
|
||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sb-select:focus, .sb-input:focus {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
.tag-suggest-row {
|
||||
display: flex;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { apiPost, apiGet } from "@/api/client";
|
||||
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
||||
import type { Note } from "@/types/note";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import TableOfContents from "@/components/TableOfContents.vue";
|
||||
@@ -79,11 +79,48 @@ watch(() => route.params.id, (newId) => {
|
||||
if (newId) loadNote(Number(newId));
|
||||
});
|
||||
|
||||
const isListNote = computed(() => {
|
||||
const body = store.currentNote?.body ?? "";
|
||||
return /^- \[[ xX]\] /m.test(body);
|
||||
});
|
||||
|
||||
const renderedBody = computed(() => {
|
||||
if (!store.currentNote) return "";
|
||||
return renderMarkdown(store.currentNote.body);
|
||||
return renderMarkdown(store.currentNote.body, { interactiveCheckboxes: isListNote.value });
|
||||
});
|
||||
|
||||
async function onBodyChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.type !== "checkbox" || !store.currentNote) return;
|
||||
|
||||
const index = parseInt(target.dataset.taskIndex ?? "", 10);
|
||||
if (isNaN(index)) return;
|
||||
|
||||
let taskIdx = 0;
|
||||
const newBody = store.currentNote.body.split("\n").map(line => {
|
||||
const stripped = line.trimStart();
|
||||
if (stripped.startsWith("- [ ] ") || stripped.startsWith("- [x] ") || stripped.startsWith("- [X] ")) {
|
||||
if (taskIdx === index) {
|
||||
const indent = line.length - stripped.length;
|
||||
const wasChecked = !stripped.startsWith("- [ ] ");
|
||||
taskIdx++;
|
||||
return " ".repeat(indent) + (wasChecked ? "- [ ] " : "- [x] ") + stripped.slice(6);
|
||||
}
|
||||
taskIdx++;
|
||||
}
|
||||
return line;
|
||||
}).join("\n");
|
||||
|
||||
// Optimistic update so the checkbox state doesn't snap back
|
||||
store.currentNote.body = newBody;
|
||||
|
||||
try {
|
||||
await apiPatch(`/api/notes/${store.currentNote.id}`, { body: newBody });
|
||||
} catch {
|
||||
await store.fetchNote(store.currentNote.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function onBodyClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
@@ -220,8 +257,10 @@ async function convertToTask() {
|
||||
</div>
|
||||
<div
|
||||
class="body prose"
|
||||
:class="{ 'prose--checklist': isListNote }"
|
||||
v-html="renderedBody"
|
||||
@click="onBodyClick"
|
||||
@change="onBodyChange"
|
||||
></div>
|
||||
|
||||
<div v-if="backlinks.length" class="backlinks">
|
||||
|
||||
@@ -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: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
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>
|
||||
@@ -16,11 +16,15 @@ interface MilestoneSummary {
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
goal: string | null;
|
||||
status: "active" | "completed" | "archived";
|
||||
color: string | null;
|
||||
auto_summary: string | null;
|
||||
permission?: string;
|
||||
is_shared?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
summary?: {
|
||||
@@ -54,19 +58,8 @@ async function loadProjects() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await apiGet<{ projects: Project[] }>("/api/projects");
|
||||
const data = await apiGet<{ projects: Project[] }>("/api/projects?include_summary=true");
|
||||
projects.value = data.projects;
|
||||
// Fetch summaries (including milestone_summary) in parallel
|
||||
await Promise.allSettled(
|
||||
projects.value.map(async (p) => {
|
||||
try {
|
||||
const full = await apiGet<Project>(`/api/projects/${p.id}`);
|
||||
p.summary = full.summary;
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
error.value = "Failed to load projects.";
|
||||
} finally {
|
||||
@@ -253,9 +246,9 @@ function truncate(text: string | null, max = 120): string {
|
||||
|
||||
<style scoped>
|
||||
.projects-list {
|
||||
max-width: 1200px;
|
||||
max-width: var(--page-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,14 @@ interface Milestone {
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
goal: string | null;
|
||||
status: "active" | "completed" | "archived";
|
||||
color: string | null;
|
||||
auto_summary: string | null;
|
||||
permission?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
summary?: {
|
||||
@@ -639,9 +642,9 @@ async function confirmDelete() {
|
||||
<style scoped>
|
||||
/* ── Layout ─────────────────────────────────────────────────── */
|
||||
.project-view {
|
||||
max-width: 1200px;
|
||||
max-width: var(--page-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
|
||||
+668
-108
@@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry } from "@/api/client";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
|
||||
import { usePushStore } from "@/stores/push";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
@@ -15,6 +15,28 @@ const toastStore = useToastStore();
|
||||
const pushStore = usePushStore();
|
||||
const assistantName = ref("");
|
||||
const defaultModel = ref("");
|
||||
const userTimezone = ref("");
|
||||
const savingTimezone = ref(false);
|
||||
const timezoneSaved = ref(false);
|
||||
|
||||
function detectTimezone() {
|
||||
userTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
async function saveTimezone() {
|
||||
savingTimezone.value = true;
|
||||
timezoneSaved.value = false;
|
||||
try {
|
||||
await apiPut('/api/settings', { user_timezone: userTimezone.value });
|
||||
timezoneSaved.value = true;
|
||||
setTimeout(() => (timezoneSaved.value = false), 2000);
|
||||
} catch {
|
||||
toastStore.show('Failed to save timezone', 'error');
|
||||
} finally {
|
||||
savingTimezone.value = false;
|
||||
}
|
||||
}
|
||||
const backgroundModel = ref("");
|
||||
const installedModels = ref<string[]>([]);
|
||||
const defaultChatModel = ref("");
|
||||
|
||||
@@ -40,7 +62,7 @@ const appVersion = ref('dev');
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||
|
||||
@@ -325,13 +347,6 @@ async function geocodeLocation(key: 'home' | 'work') {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWorkDay(day: number) {
|
||||
const days = briefingConfig.value.work_days;
|
||||
const idx = days.indexOf(day);
|
||||
if (idx === -1) days.push(day);
|
||||
else days.splice(idx, 1);
|
||||
days.sort();
|
||||
}
|
||||
|
||||
|
||||
async function saveBriefingSettings() {
|
||||
@@ -413,6 +428,28 @@ const notifySecurityAlerts = ref(true);
|
||||
const savingNotifications = ref(false);
|
||||
const notificationsSaved = ref(false);
|
||||
|
||||
// VAPID key reset (admin)
|
||||
const vapidResetting = ref(false);
|
||||
const vapidResetMsg = ref("");
|
||||
const vapidResetError = ref(false);
|
||||
|
||||
async function resetVapidKeys() {
|
||||
if (!confirm("This will regenerate VAPID keys and clear all push subscriptions. You will need to re-enable notifications afterwards. Continue?")) return;
|
||||
vapidResetting.value = true;
|
||||
vapidResetMsg.value = "";
|
||||
vapidResetError.value = false;
|
||||
try {
|
||||
await apiPost("/api/push/reset-vapid", {});
|
||||
vapidResetMsg.value = "Keys regenerated. Please re-enable notifications in this browser.";
|
||||
pushStore.checkSubscription();
|
||||
} catch {
|
||||
vapidResetError.value = true;
|
||||
vapidResetMsg.value = "Reset failed — check server logs.";
|
||||
} finally {
|
||||
vapidResetting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// CalDAV settings (per-user)
|
||||
const caldav = ref({
|
||||
caldav_url: "",
|
||||
@@ -463,6 +500,8 @@ async function saveAdminVoice() {
|
||||
adminVoiceSaved.value = true;
|
||||
setTimeout(() => { adminVoiceSaved.value = false; }, 2000);
|
||||
voiceTabLoaded.value = false;
|
||||
// Always update global voice state so mic buttons appear/disappear immediately
|
||||
await store.checkVoiceStatus();
|
||||
if (adminVoiceEnabled.value) {
|
||||
await reloadVoiceModels();
|
||||
}
|
||||
@@ -484,6 +523,8 @@ async function reloadVoiceModels() {
|
||||
if (status.stt && status.tts) {
|
||||
voiceStatus.value = status;
|
||||
voiceTabLoaded.value = true;
|
||||
// Propagate to global store so mic buttons appear everywhere
|
||||
store.checkVoiceStatus();
|
||||
break;
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
@@ -514,6 +555,47 @@ const savingVoice = ref(false);
|
||||
const voiceSaved = ref(false);
|
||||
const voiceTabLoaded = ref(false);
|
||||
|
||||
// Voice blend
|
||||
const blendEnabled = ref(false);
|
||||
const voiceBlend = ref<VoiceBlendEntry[]>([
|
||||
{ voice: "af_heart", weight: 1.0 },
|
||||
{ voice: "af_bella", weight: 1.0 },
|
||||
]);
|
||||
const blendPreviewText = ref("Hello! I'm your assistant. How can I help you today?");
|
||||
const blendPreviewing = ref(false);
|
||||
|
||||
function addBlendSlot() {
|
||||
voiceBlend.value.push({ voice: "af_heart", weight: 1.0 });
|
||||
}
|
||||
|
||||
function removeBlendSlot(idx: number) {
|
||||
if (voiceBlend.value.length > 2) voiceBlend.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
async function previewBlend() {
|
||||
if (blendPreviewing.value || !blendPreviewText.value.trim()) return;
|
||||
blendPreviewing.value = true;
|
||||
// Create AudioContext synchronously in user-gesture context to bypass autoplay policy
|
||||
const ctx = new AudioContext();
|
||||
try {
|
||||
const blob = await synthesiseSpeech(
|
||||
blendPreviewText.value,
|
||||
undefined,
|
||||
voiceTtsSpeed.value,
|
||||
voiceBlend.value,
|
||||
);
|
||||
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
src.start(0);
|
||||
} catch {
|
||||
toastStore.show("Preview failed — TTS may not be ready", "error");
|
||||
} finally {
|
||||
blendPreviewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVoiceTab() {
|
||||
if (voiceTabLoaded.value) return;
|
||||
voiceTabLoaded.value = true;
|
||||
@@ -530,6 +612,31 @@ async function loadVoiceTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const voicePreviewing = ref(false);
|
||||
|
||||
async function previewVoice() {
|
||||
if (voicePreviewing.value) return;
|
||||
voicePreviewing.value = true;
|
||||
// Create AudioContext synchronously in user-gesture context to bypass autoplay policy
|
||||
const ctx = new AudioContext();
|
||||
try {
|
||||
const blob = await synthesiseSpeech(
|
||||
"Hello! I'm your assistant. How can I help you today?",
|
||||
voiceTtsVoice.value,
|
||||
voiceTtsSpeed.value,
|
||||
);
|
||||
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
src.start(0);
|
||||
} catch {
|
||||
toastStore.show("Preview failed — TTS may not be ready", "error");
|
||||
} finally {
|
||||
voicePreviewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVoiceSettings() {
|
||||
savingVoice.value = true;
|
||||
voiceSaved.value = false;
|
||||
@@ -538,6 +645,7 @@ async function saveVoiceSettings() {
|
||||
voice_tts_voice: voiceTtsVoice.value,
|
||||
voice_tts_speed: String(voiceTtsSpeed.value),
|
||||
voice_speech_style: voiceSpeechStyle.value,
|
||||
voice_tts_blend: blendEnabled.value ? JSON.stringify(voiceBlend.value) : "",
|
||||
});
|
||||
voiceSaved.value = true;
|
||||
setTimeout(() => { voiceSaved.value = false; }, 2000);
|
||||
@@ -546,6 +654,76 @@ async function saveVoiceSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Profile ──────────────────────────────────────────────────────────────────
|
||||
const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const profile = ref<UserProfile>({
|
||||
display_name: '', job_title: '', industry: '',
|
||||
expertise_level: 'intermediate', response_style: 'balanced', tone: 'casual',
|
||||
interests: [], work_schedule: {}, learned_summary: '',
|
||||
observations_count: 0, observations_updated_at: null,
|
||||
})
|
||||
const profileSaving = ref(false)
|
||||
const profileSaved = ref(false)
|
||||
const consolidating = ref(false)
|
||||
const clearingObs = ref(false)
|
||||
|
||||
async function loadProfile() {
|
||||
try { profile.value = await getProfile() } catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
profileSaving.value = true
|
||||
profileSaved.value = false
|
||||
try {
|
||||
profile.value = await updateProfile({
|
||||
display_name: profile.value.display_name,
|
||||
job_title: profile.value.job_title,
|
||||
industry: profile.value.industry,
|
||||
expertise_level: profile.value.expertise_level,
|
||||
response_style: profile.value.response_style,
|
||||
tone: profile.value.tone,
|
||||
interests: profile.value.interests,
|
||||
work_schedule: profile.value.work_schedule,
|
||||
})
|
||||
profileSaved.value = true
|
||||
setTimeout(() => { profileSaved.value = false }, 2000)
|
||||
} catch { toastStore.show('Failed to save profile', 'error') }
|
||||
finally { profileSaving.value = false }
|
||||
}
|
||||
|
||||
function toggleProfileWorkDay(day: string) {
|
||||
const days = [...(profile.value.work_schedule.days ?? [])]
|
||||
const idx = days.indexOf(day)
|
||||
if (idx >= 0) days.splice(idx, 1)
|
||||
else days.push(day)
|
||||
profile.value.work_schedule = { ...profile.value.work_schedule, days }
|
||||
}
|
||||
|
||||
async function runConsolidate() {
|
||||
consolidating.value = true
|
||||
try {
|
||||
const result = await consolidateProfile()
|
||||
profile.value.learned_summary = result.learned_summary
|
||||
toastStore.show('Profile updated from observations')
|
||||
} catch { toastStore.show('Consolidation failed', 'error') }
|
||||
finally { consolidating.value = false }
|
||||
}
|
||||
|
||||
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
|
||||
|
||||
async function clearObservations() {
|
||||
if (!confirm('Clear all learned observations and the generated summary? This cannot be undone.')) return
|
||||
clearingObs.value = true
|
||||
try {
|
||||
await clearProfileObservations()
|
||||
profile.value.learned_summary = ''
|
||||
profile.value.observations_count = 0
|
||||
profile.value.observations_updated_at = null
|
||||
toastStore.show('Learned data cleared')
|
||||
} catch { toastStore.show('Failed to clear observations', 'error') }
|
||||
finally { clearingObs.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const v = await apiGet<{ version: string }>('/api/version')
|
||||
@@ -569,6 +747,8 @@ onMounted(async () => {
|
||||
// Load notification preferences from user settings
|
||||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||||
defaultModel.value = allSettings.default_model ?? "";
|
||||
backgroundModel.value = allSettings.background_model ?? "";
|
||||
userTimezone.value = allSettings.user_timezone ?? "";
|
||||
chatRetentionDays.value = allSettings.chat_retention_days !== undefined
|
||||
? Number(allSettings.chat_retention_days)
|
||||
: 90;
|
||||
@@ -579,10 +759,22 @@ onMounted(async () => {
|
||||
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
|
||||
}
|
||||
|
||||
// Load user profile
|
||||
await loadProfile();
|
||||
|
||||
// Load voice settings
|
||||
if (allSettings.voice_tts_voice) voiceTtsVoice.value = allSettings.voice_tts_voice;
|
||||
if (allSettings.voice_tts_speed) voiceTtsSpeed.value = parseFloat(allSettings.voice_tts_speed);
|
||||
if (allSettings.voice_speech_style) voiceSpeechStyle.value = allSettings.voice_speech_style;
|
||||
if (allSettings.voice_tts_blend) {
|
||||
try {
|
||||
const parsed = JSON.parse(allSettings.voice_tts_blend);
|
||||
if (Array.isArray(parsed) && parsed.length >= 2) {
|
||||
voiceBlend.value = parsed;
|
||||
blendEnabled.value = true;
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
}
|
||||
|
||||
// Load CalDAV settings
|
||||
try {
|
||||
@@ -778,6 +970,7 @@ async function saveAssistant() {
|
||||
await store.updateSettings({
|
||||
assistant_name: assistantName.value.trim() || "Fable",
|
||||
default_model: defaultModel.value,
|
||||
background_model: backgroundModel.value,
|
||||
});
|
||||
saved.value = true;
|
||||
setTimeout(() => (saved.value = false), 2000);
|
||||
@@ -1240,7 +1433,7 @@ function formatUserDate(iso: string): string {
|
||||
<div class="sidebar-group">
|
||||
<div class="sidebar-group-label">User</div>
|
||||
<button
|
||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
|
||||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -1286,6 +1479,20 @@ function formatUserDate(iso: string): string {
|
||||
</select>
|
||||
<p class="field-hint">Model used for new conversations.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="background-model">Background Model</label>
|
||||
<select id="background-model" v-model="backgroundModel" class="input">
|
||||
<option value="">Default (qwen2.5:0.5b)</option>
|
||||
<option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Model used for background tasks: title generation, tag suggestions, project summaries, and RSS classification.
|
||||
Using a small dedicated model (e.g. qwen2.5:0.5b) keeps the chat model's KV cache warm between messages, significantly reducing response time.
|
||||
<span v-if="backgroundModel && backgroundModel === (defaultModel || defaultChatModel)" class="field-hint-warn">
|
||||
⚠ Setting this to the same model as Chat Model will wipe the KV cache after every background task, increasing response latency.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveAssistant" :disabled="saving">
|
||||
@@ -1295,6 +1502,32 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Timezone -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Timezone</h2>
|
||||
<p class="section-desc">Used to schedule briefings and format times in chat. Set this to your local IANA timezone (e.g. America/New_York, Europe/London).</p>
|
||||
<div class="field">
|
||||
<label for="user-timezone">Your timezone</label>
|
||||
<div style="display:flex; gap:0.5rem; align-items:center">
|
||||
<input
|
||||
id="user-timezone"
|
||||
v-model="userTimezone"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="e.g. America/New_York"
|
||||
/>
|
||||
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
|
||||
</div>
|
||||
<p class="field-hint">Click Detect to auto-fill from your browser.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
|
||||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Model Management -->
|
||||
<section class="settings-section full-width">
|
||||
<div class="model-mgmt-header">
|
||||
@@ -1362,93 +1595,104 @@ function formatUserDate(iso: string): string {
|
||||
<!-- ── Account ── -->
|
||||
<div v-show="activeTab === 'account'" class="settings-grid">
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Email Address</h2>
|
||||
<p class="section-desc">Used for password resets and notifications.</p>
|
||||
<div class="field">
|
||||
<label for="new-email">Email</label>
|
||||
<input
|
||||
id="new-email"
|
||||
v-model="newEmail"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="authStore.user?.has_password" class="field">
|
||||
<label for="email-password">Current Password</label>
|
||||
<input
|
||||
id="email-password"
|
||||
v-model="emailPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
/>
|
||||
<p class="field-hint">Required to confirm the change.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
@click="changeEmail"
|
||||
:disabled="changingEmail || (authStore.user?.has_password && !emailPassword)"
|
||||
>
|
||||
{{ changingEmail ? "Saving..." : "Save Email" }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- SSO accounts: no local credential management -->
|
||||
<section v-if="!authStore.user?.has_password" class="settings-section">
|
||||
<h2>Account</h2>
|
||||
<p class="section-desc">
|
||||
Your account is managed by an external identity provider.
|
||||
Email and password changes are made through your provider, not here.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Change Password</h2>
|
||||
<div class="field">
|
||||
<label for="current-password">Current Password</label>
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p class="field-hint">Must be at least 8 characters</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirm-new-password">Confirm New Password</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
v-model="confirmNewPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
|
||||
/>
|
||||
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
@click="changePassword"
|
||||
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
|
||||
>
|
||||
{{ changingPassword ? "Changing..." : "Change Password" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<template v-if="authStore.user?.has_password">
|
||||
<section class="settings-section">
|
||||
<h2>Email Address</h2>
|
||||
<p class="section-desc">Used for password resets and notifications.</p>
|
||||
<div class="field">
|
||||
<label for="new-email">Email</label>
|
||||
<input
|
||||
id="new-email"
|
||||
v-model="newEmail"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email-password">Current Password</label>
|
||||
<input
|
||||
id="email-password"
|
||||
v-model="emailPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
/>
|
||||
<p class="field-hint">Required to confirm the change.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
@click="changeEmail"
|
||||
:disabled="changingEmail || !emailPassword"
|
||||
>
|
||||
{{ changingEmail ? "Saving..." : "Save Email" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Change Password</h2>
|
||||
<div class="field">
|
||||
<label for="current-password">Current Password</label>
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p class="field-hint">Must be at least 8 characters</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirm-new-password">Confirm New Password</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
v-model="confirmNewPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
|
||||
/>
|
||||
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
@click="changePassword"
|
||||
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
|
||||
>
|
||||
{{ changingPassword ? "Changing..." : "Change Password" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Active Sessions</h2>
|
||||
<p class="section-desc">
|
||||
Sign out all other devices and sessions. Use this after an SSO password change
|
||||
Sign out all other devices and sessions. Use this after a password change
|
||||
to ensure stale sessions are revoked.
|
||||
</p>
|
||||
<div class="actions">
|
||||
@@ -1461,6 +1705,128 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
|
||||
<!-- ── Notifications ── -->
|
||||
<!-- ── Profile ── -->
|
||||
<div v-show="activeTab === 'profile'" class="settings-grid">
|
||||
<section class="settings-section full-width">
|
||||
<h2>About You</h2>
|
||||
<p class="section-desc">This information is used by the assistant to personalise responses in chat and briefings.</p>
|
||||
<div class="assistant-grid">
|
||||
<div class="field">
|
||||
<label>Display Name</label>
|
||||
<input v-model="profile.display_name" type="text" class="input" placeholder="e.g. Alex" />
|
||||
<p class="field-hint">How the assistant addresses you.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Job Title</label>
|
||||
<input v-model="profile.job_title" type="text" class="input" placeholder="e.g. Product Manager" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Industry</label>
|
||||
<input v-model="profile.industry" type="text" class="input" placeholder="e.g. Technology" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Expertise Level</label>
|
||||
<select v-model="profile.expertise_level" class="input">
|
||||
<option value="novice">Novice — explain things simply</option>
|
||||
<option value="intermediate">Intermediate — balanced explanations</option>
|
||||
<option value="expert">Expert — assume deep knowledge</option>
|
||||
</select>
|
||||
<p class="field-hint">Calibrates how the assistant explains concepts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Response Preferences</h2>
|
||||
<div class="assistant-grid">
|
||||
<div class="field">
|
||||
<label>Response Style</label>
|
||||
<select v-model="profile.response_style" class="input">
|
||||
<option value="concise">Concise — short and direct</option>
|
||||
<option value="balanced">Balanced — default</option>
|
||||
<option value="detailed">Detailed — thorough explanations</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Tone</label>
|
||||
<select v-model="profile.tone" class="input">
|
||||
<option value="casual">Casual — friendly and relaxed</option>
|
||||
<option value="professional">Professional — formal and precise</option>
|
||||
<option value="technical">Technical — jargon-friendly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Interests</h2>
|
||||
<p class="section-desc">Topics you care about — used to personalise news and briefing context.</p>
|
||||
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Work Schedule</h2>
|
||||
<p class="section-desc">Helps the briefing understand when you're working and what's relevant each morning.</p>
|
||||
<div class="field">
|
||||
<label>Work Days</label>
|
||||
<div class="day-picker">
|
||||
<button
|
||||
v-for="day in WORK_DAYS"
|
||||
:key="day"
|
||||
class="day-btn"
|
||||
:class="{ active: (profile.work_schedule.days ?? []).includes(day) }"
|
||||
@click="toggleProfileWorkDay(day)"
|
||||
type="button"
|
||||
>{{ day }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assistant-grid" style="margin-top:0.75rem">
|
||||
<div class="field">
|
||||
<label>Start Time</label>
|
||||
<input v-model="profile.work_schedule.start" type="time" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>End Time</label>
|
||||
<input v-model="profile.work_schedule.end" type="time" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>What the Assistant Has Learned</h2>
|
||||
<p class="section-desc">
|
||||
The assistant observes patterns from your briefing conversations and builds a summary over time.
|
||||
<span v-if="profile.observations_count > 0"> {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.</span>
|
||||
</p>
|
||||
<div v-if="profile.learned_summary" class="learned-summary">{{ profile.learned_summary }}</div>
|
||||
<div v-else class="learned-empty">No learned summary yet. Observations accumulate from daily briefing conversations.</div>
|
||||
<div class="actions" style="gap:0.5rem;flex-wrap:wrap">
|
||||
<button class="btn-secondary" @click="runConsolidate" :disabled="consolidating || profile.observations_count === 0">
|
||||
{{ consolidating ? 'Consolidating…' : 'Consolidate Now' }}
|
||||
</button>
|
||||
<button class="btn-danger-outline" @click="clearObservations" :disabled="clearingObs">
|
||||
{{ clearingObs ? 'Clearing…' : 'Reset Learned Data' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'notifications'" class="settings-grid">
|
||||
|
||||
<section class="settings-section">
|
||||
@@ -1540,6 +1906,22 @@ function formatUserDate(iso: string): string {
|
||||
Notifications are blocked. Allow them in your browser site settings to re-enable.
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="authStore.isAdmin">
|
||||
<div class="field-divider" style="margin: 1rem 0;" />
|
||||
<p class="section-desc">
|
||||
If push notifications fail due to a corrupted or misformatted VAPID key, regenerate
|
||||
the key pair here. All existing subscriptions will be cleared — you will need to
|
||||
re-enable notifications in this browser afterwards.
|
||||
</p>
|
||||
<div class="actions" style="margin-top: 0.5rem;">
|
||||
<button class="btn-danger" :disabled="vapidResetting" @click="resetVapidKeys">
|
||||
{{ vapidResetting ? 'Regenerating…' : 'Regenerate VAPID Keys' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="vapidResetMsg" :class="vapidResetError ? 'field-error' : 'field-hint'" style="margin-top: 0.5rem;">
|
||||
{{ vapidResetMsg }}
|
||||
</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
@@ -1774,21 +2156,6 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Work schedule -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Office Days</h2>
|
||||
<p class="section-desc">Which days do you go into the office? Used to decide whether to include the 8am check-in slot.</p>
|
||||
<div class="briefing-day-toggles">
|
||||
<button
|
||||
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="idx"
|
||||
:class="['briefing-day-btn', { active: briefingConfig.work_days.includes(idx) }]"
|
||||
@click="toggleWorkDay(idx)"
|
||||
type="button"
|
||||
>{{ day }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Slots -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Scheduled Slots</h2>
|
||||
@@ -1814,7 +2181,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint" style="margin-top: 0.5rem">
|
||||
Firing in timezone: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
|
||||
Firing in timezone: <strong>{{ userTimezone || 'UTC (not configured — set in General settings)' }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -2006,12 +2373,80 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-secondary" @click="previewVoice" :disabled="voicePreviewing || !voiceStatus?.tts">
|
||||
{{ voicePreviewing ? 'Generating…' : '▶ Preview' }}
|
||||
</button>
|
||||
<button class="btn-save" @click="saveVoiceSettings" :disabled="savingVoice">
|
||||
{{ voiceSaved ? 'Saved ✓' : savingVoice ? 'Saving…' : 'Save Voice Settings' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="voiceStatus?.enabled && voiceStatus?.tts" class="settings-section full-width">
|
||||
<h2>Voice Blend</h2>
|
||||
<p class="section-desc">
|
||||
Mix two or more voice styles by weight. The resulting blended voice is used instead of
|
||||
the single voice above when enabled.
|
||||
</p>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" v-model="blendEnabled" />
|
||||
<span>Enable voice blending</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="blendEnabled">
|
||||
<div class="blend-slots">
|
||||
<div v-for="(entry, idx) in voiceBlend" :key="idx" class="blend-slot">
|
||||
<select class="input blend-voice-select" v-model="entry.voice">
|
||||
<option v-for="v in availableVoices" :key="v.id" :value="v.id">{{ v.label }}</option>
|
||||
</select>
|
||||
<div class="blend-weight-row">
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2.0"
|
||||
step="0.05"
|
||||
v-model.number="entry.weight"
|
||||
class="range-input blend-weight-slider"
|
||||
/>
|
||||
<span class="blend-weight-label">{{ entry.weight.toFixed(2) }}</span>
|
||||
<button
|
||||
v-if="voiceBlend.length > 2"
|
||||
class="btn-remove-slot"
|
||||
@click="removeBlendSlot(idx)"
|
||||
title="Remove this voice"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blend-actions">
|
||||
<button class="btn-secondary" @click="addBlendSlot" :disabled="voiceBlend.length >= 5">
|
||||
+ Add voice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field-group" style="margin-top: 1rem;">
|
||||
<label class="field-label">Preview text</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
v-model="blendPreviewText"
|
||||
placeholder="Text to speak for preview…"
|
||||
maxlength="300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-secondary" @click="previewBlend" :disabled="blendPreviewing">
|
||||
{{ blendPreviewing ? 'Generating…' : '▶ Preview blend' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── API Keys ── -->
|
||||
@@ -2206,7 +2641,7 @@ FABLE_API_KEY=<your-api-key></pre>
|
||||
<h2>Voice (Speech-to-Speech)</h2>
|
||||
<p class="section-desc">
|
||||
Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS).
|
||||
Models load at startup — a server restart is required after changing these settings.
|
||||
Save settings then click "Reload models" to apply without restarting the server.
|
||||
Install dependencies first: <code>pip install faster-whisper kokoro soundfile</code>
|
||||
</p>
|
||||
<div class="checkbox-field">
|
||||
@@ -2737,6 +3172,11 @@ FABLE_API_KEY=<your-api-key></pre>
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.field-hint-warn {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
color: #f59e0b;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3850,6 +4290,126 @@ FABLE_API_KEY=<your-api-key></pre>
|
||||
}
|
||||
.voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
/* ── Voice blend ────────────────────────────────────────────────────────── */
|
||||
.blend-slots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.blend-slot {
|
||||
background: color-mix(in srgb, var(--color-surface) 60%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.blend-voice-select {
|
||||
width: 100%;
|
||||
}
|
||||
.blend-weight-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.blend-weight-slider {
|
||||
flex: 1;
|
||||
}
|
||||
.blend-weight-label {
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-remove-slot {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-remove-slot:hover {
|
||||
color: var(--color-danger, #e05555);
|
||||
border-color: var(--color-danger, #e05555);
|
||||
}
|
||||
.blend-actions {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Profile tab ─────────────────────────────────────────────────────────── */
|
||||
.day-picker {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.day-btn {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.day-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.day-btn.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.learned-summary {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.learned-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-danger-outline {
|
||||
padding: 0.45rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover:not(:disabled) {
|
||||
background: var(--color-danger, #e74c3c);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
|
||||
@keyframes va-dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
|
||||
@@ -333,7 +333,7 @@ async function save() {
|
||||
savedParentId = parentId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Task saved");
|
||||
window.location.reload();
|
||||
router.push(`/tasks/${taskId.value}`);
|
||||
} else {
|
||||
const task = await store.createTask(data);
|
||||
dirty.value = false;
|
||||
|
||||
@@ -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: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
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>
|
||||
@@ -3,18 +3,13 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import ChatMessage from "@/components/ChatMessage.vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
|
||||
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
const projectId = computed(() => Number(route.params.projectId));
|
||||
@@ -26,10 +21,9 @@ interface Project {
|
||||
}
|
||||
|
||||
const project = ref<Project | null>(null);
|
||||
const messageInput = ref("");
|
||||
const messagesEl = ref<HTMLElement | null>(null);
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
||||
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
||||
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
|
||||
const activeNoteId = ref<number | null>(null);
|
||||
let workspaceConvId: number | null = null;
|
||||
let isNewConv = false;
|
||||
@@ -64,7 +58,7 @@ const gridColumns = computed(() => {
|
||||
].join(" ");
|
||||
});
|
||||
|
||||
// SSE watcher
|
||||
// SSE watcher — auto-load notes/tasks when tool calls succeed
|
||||
const processedCount = ref(0);
|
||||
|
||||
watch(
|
||||
@@ -78,6 +72,7 @@ watch(
|
||||
tc.result?.data?.id
|
||||
) {
|
||||
activeNoteId.value = tc.result.data.id as number;
|
||||
noteEditorRef.value?.reload();
|
||||
}
|
||||
if (
|
||||
["create_task", "update_task", "create_milestone", "update_milestone"].includes(tc.function) &&
|
||||
@@ -94,29 +89,10 @@ watch(
|
||||
watch(
|
||||
() => chatStore.streaming,
|
||||
(s) => {
|
||||
if (!s) {
|
||||
processedCount.value = 0;
|
||||
scrollToBottom();
|
||||
}
|
||||
if (!s) processedCount.value = 0;
|
||||
}
|
||||
);
|
||||
|
||||
const streamingRendered = computed(() => {
|
||||
if (!chatStore.streamingContent) return "";
|
||||
return renderMarkdown(chatStore.streamingContent);
|
||||
});
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesEl.value) {
|
||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => chatStore.streamingContent, scrollToBottom);
|
||||
watch(() => chatStore.currentConversation?.messages.length, scrollToBottom);
|
||||
|
||||
function togglePanel(panel: keyof typeof panelOpen.value) {
|
||||
const open = panelOpen.value;
|
||||
const openCount = [open.tasks, open.chat, open.notes].filter(Boolean).length;
|
||||
@@ -126,54 +102,7 @@ function togglePanel(panel: keyof typeof panelOpen.value) {
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
messageInput.value = text;
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const content = messageInput.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
messageInput.value = "";
|
||||
resetTextareaHeight();
|
||||
|
||||
await chatStore.sendMessage(
|
||||
content,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
projectId.value,
|
||||
projectId.value,
|
||||
);
|
||||
scrollToBottom();
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
// Prevent App.vue from navigating home when textarea is focused
|
||||
e.stopPropagation();
|
||||
inputEl.value?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + "px";
|
||||
}
|
||||
|
||||
function resetTextareaHeight() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
chatPanelRef.value?.prefill(text);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -214,7 +143,7 @@ onMounted(async () => {
|
||||
localStorage.setItem(key, String(conv.id));
|
||||
}
|
||||
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
nextTick(() => chatPanelRef.value?.focus());
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
@@ -279,114 +208,30 @@ onUnmounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Center: Chat -->
|
||||
<div v-show="panelOpen.chat" class="ws-panel ws-chat-panel">
|
||||
<div class="chat-messages" ref="messagesEl">
|
||||
<template v-if="chatStore.currentConversation">
|
||||
<ChatMessage
|
||||
v-for="msg in chatStore.currentConversation.messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Streaming bubble -->
|
||||
<div v-if="chatStore.streaming" class="chat-message role-assistant">
|
||||
<div class="message-bubble streaming-bubble">
|
||||
<div class="message-header">
|
||||
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
||||
</div>
|
||||
<div v-if="chatStore.streamingToolCalls.length" class="streaming-tool-calls">
|
||||
<ToolCallCard
|
||||
v-for="(tc, i) in chatStore.streamingToolCalls"
|
||||
:key="i"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
<ToolConfirmCard
|
||||
v-if="chatStore.streamingPendingTool"
|
||||
:pending-tool="chatStore.streamingPendingTool"
|
||||
@accept="chatStore.confirmTool(true)"
|
||||
@decline="chatStore.confirmTool(false)"
|
||||
/>
|
||||
<div v-if="chatStore.streamingStatus" class="streaming-status-line">
|
||||
<span class="streaming-status-dot"></span>
|
||||
{{ chatStore.streamingStatus }}
|
||||
</div>
|
||||
<details
|
||||
v-if="chatStore.streamingThinking"
|
||||
class="thinking-block"
|
||||
:open="!chatStore.streamingContent"
|
||||
>
|
||||
<summary class="thinking-summary">Reasoning</summary>
|
||||
<pre class="thinking-text">{{ chatStore.streamingThinking }}</pre>
|
||||
</details>
|
||||
<div class="message-content prose" v-html="streamingRendered"></div>
|
||||
<span
|
||||
v-if="!chatStore.streamingStatus && !chatStore.streamingThinking"
|
||||
class="typing-indicator"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queued messages — shown as pending bubbles -->
|
||||
<template v-if="chatStore.queuedMessages.length">
|
||||
<div v-show="panelOpen.chat" class="ws-panel ws-panel-chat">
|
||||
<Transition name="panel-fade">
|
||||
<div v-if="panelOpen.chat" class="panel-inner panel-inner-chat">
|
||||
<!-- Quick chips (shown when conversation is empty) -->
|
||||
<div
|
||||
v-for="(q, i) in chatStore.queuedMessages"
|
||||
:key="`queued-${i}`"
|
||||
class="ws-message role-user queued-message"
|
||||
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
|
||||
class="empty-chat-prompt"
|
||||
>
|
||||
<div class="message-bubble queued-bubble">
|
||||
<div class="queued-badge">Queued</div>
|
||||
<div class="message-content">{{ q.content }}</div>
|
||||
<p class="empty-hint">What would you like to work on?</p>
|
||||
<div class="quick-chips">
|
||||
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
|
||||
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
|
||||
<button class="quick-chip" @click="prefill('Add tasks for ')">✓ Add tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queued-clear-row">
|
||||
<button class="queued-clear-btn" @click="chatStore.clearQueue()" aria-label="Cancel queued messages">
|
||||
Cancel {{ chatStore.queuedMessages.length }} queued
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
|
||||
class="empty-chat-prompt"
|
||||
>
|
||||
<p class="empty-hint">What would you like to work on?</p>
|
||||
<div class="quick-chips">
|
||||
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
|
||||
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
|
||||
<button class="quick-chip" @click="prefill('Add tasks for ')">✓ Add tasks</button>
|
||||
</div>
|
||||
<ChatPanel
|
||||
ref="chatPanelRef"
|
||||
variant="full"
|
||||
:projectId="projectId"
|
||||
placeholder="Message the agent… (Enter to send)"
|
||||
class="ws-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
class="chat-input"
|
||||
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent… (Enter to send)'"
|
||||
rows="1"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
></textarea>
|
||||
<button
|
||||
v-if="chatStore.streaming"
|
||||
class="btn-abort"
|
||||
title="Stop generation"
|
||||
@click="chatStore.cancelGeneration()"
|
||||
>
|
||||
■ Stop
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-send"
|
||||
:disabled="!messageInput.trim()"
|
||||
@click="sendMessage"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Right: Note Editor -->
|
||||
@@ -395,6 +240,7 @@ onUnmounted(async () => {
|
||||
<div v-if="panelOpen.notes" class="panel-inner">
|
||||
<WorkspaceNoteEditor
|
||||
v-if="project"
|
||||
ref="noteEditorRef"
|
||||
:project-id="project.id"
|
||||
:active-note-id="activeNoteId"
|
||||
/>
|
||||
@@ -499,25 +345,31 @@ onUnmounted(async () => {
|
||||
}
|
||||
|
||||
/* Chat panel */
|
||||
.ws-chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.ws-panel-chat {
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.panel-inner-chat {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ws-chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-chat-prompt {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.empty-hint {
|
||||
margin: 0 0 1rem;
|
||||
@@ -529,6 +381,7 @@ onUnmounted(async () => {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.quick-chip {
|
||||
background: var(--color-surface);
|
||||
@@ -546,206 +399,4 @@ onUnmounted(async () => {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.chat-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.btn-send:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-abort {
|
||||
background: none;
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-abort:hover {
|
||||
background: var(--color-danger, #e74c3c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Streaming indicators (mirror ChatView) */
|
||||
.chat-message {
|
||||
display: flex;
|
||||
}
|
||||
.role-assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.message-bubble {
|
||||
max-width: 85%;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
}
|
||||
.message-header {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.role-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.streaming-tool-calls {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.streaming-status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.streaming-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.typing-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.message-content :deep(p) { margin: 0 0 0.5em; }
|
||||
.message-content :deep(p:last-child) { margin-bottom: 0; }
|
||||
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.thinking-summary {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.thinking-summary::-webkit-details-marker { display: none; }
|
||||
.thinking-summary::before {
|
||||
content: "▶";
|
||||
font-size: 0.6rem;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
details[open] .thinking-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.thinking-text {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ws-message.role-user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.queued-bubble {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 18px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
opacity: 0.45;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
.queued-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.queued-clear-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.queued-clear-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.queued-clear-btn:hover {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,8 @@ dependencies = [
|
||||
"caldav>=1.3",
|
||||
"icalendar>=5.0",
|
||||
"feedparser>=6.0",
|
||||
"html2text>=2024.2",
|
||||
"trafilatura>=1.12",
|
||||
"APScheduler>=3.10,<4.0",
|
||||
"pywebpush>=2.0",
|
||||
]
|
||||
|
||||
+94
-25
@@ -32,6 +32,8 @@ from fabledassistant.routes.api_keys import api_keys_bp
|
||||
from fabledassistant.routes.events import events_bp
|
||||
from fabledassistant.routes.search import search_bp
|
||||
from fabledassistant.routes.voice import voice_bp
|
||||
from fabledassistant.routes.profile import profile_bp
|
||||
from fabledassistant.routes.knowledge import knowledge_bp
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -94,6 +96,8 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(events_bp)
|
||||
app.register_blueprint(search_bp)
|
||||
app.register_blueprint(voice_bp)
|
||||
app.register_blueprint(profile_bp)
|
||||
app.register_blueprint(knowledge_bp)
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
@@ -169,64 +173,112 @@ def create_app() -> Quart:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/generate",
|
||||
json={"model": model, "prompt": "", "keep_alive": "30m"},
|
||||
json={"model": model, "prompt": "", "keep_alive": "2h"},
|
||||
)
|
||||
logger.info("Warmed model '%s' into VRAM", model)
|
||||
except Exception:
|
||||
logger.warning("Failed to warm model '%s'", model, exc_info=True)
|
||||
|
||||
async def _prime_kv_cache(user_id: int, model: str) -> None:
|
||||
"""Send a minimal chat request to prime Ollama's KV cache with the user's system prompt.
|
||||
|
||||
This ensures the next real user message only needs to process its own tokens
|
||||
rather than the full ~4,650-token system prompt, cutting TTFT from ~25s to <1s.
|
||||
The num_ctx must match what real requests will use so Ollama doesn't reload.
|
||||
"""
|
||||
try:
|
||||
from fabledassistant.services.llm import build_context, pick_num_ctx
|
||||
messages, _ = await build_context(
|
||||
user_id=user_id,
|
||||
history=[],
|
||||
current_note_id=None,
|
||||
user_message=" ",
|
||||
)
|
||||
num_ctx = pick_num_ctx(messages)
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/chat",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"num_predict": 1, "num_ctx": num_ctx},
|
||||
"keep_alive": "2h",
|
||||
},
|
||||
)
|
||||
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
|
||||
except Exception:
|
||||
logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True)
|
||||
|
||||
async def _warm_user_models() -> None:
|
||||
"""
|
||||
Warm whichever chat model(s) users have selected in Settings.
|
||||
Pull any user-configured models that are missing from Ollama, then warm
|
||||
them and prime the KV cache with each user's system prompt.
|
||||
|
||||
Only warms models that are already installed in Ollama — never auto-pulls.
|
||||
Handles both default_model (chat) and background_model user overrides.
|
||||
Falls back silently if no user preferences exist or Ollama is unreachable.
|
||||
"""
|
||||
from sqlalchemy import select as sa_select, distinct
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.setting import Setting
|
||||
|
||||
# 1. Collect all distinct default_model values users have saved.
|
||||
# 1. Collect all user model preferences (both chat and background).
|
||||
try:
|
||||
async with async_session() as session:
|
||||
rows = await session.execute(
|
||||
sa_select(distinct(Setting.value)).where(
|
||||
Setting.key == "default_model",
|
||||
sa_select(Setting.user_id, Setting.key, Setting.value).where(
|
||||
Setting.key.in_(["default_model", "background_model"]),
|
||||
Setting.value.isnot(None),
|
||||
Setting.value != "",
|
||||
)
|
||||
)
|
||||
user_models: set[str] = {r for (r,) in rows}
|
||||
settings_rows: list[tuple[int, str, str]] = list(rows)
|
||||
except Exception:
|
||||
logger.debug("Could not read user model preferences from DB", exc_info=True)
|
||||
return
|
||||
|
||||
if not user_models:
|
||||
if not settings_rows:
|
||||
logger.debug("No user model preferences found; skipping warm-up")
|
||||
return
|
||||
|
||||
# 2. Ask Ollama which models are currently installed.
|
||||
# 2. Build the set of unique models to ensure, and the list of
|
||||
# (user_id, chat_model) pairs for KV-cache priming.
|
||||
all_models: set[str] = set()
|
||||
user_chat_models: list[tuple[int, str]] = []
|
||||
for user_id_val, key, model in settings_rows:
|
||||
all_models.add(model)
|
||||
if key == "default_model":
|
||||
user_chat_models.append((user_id_val, model))
|
||||
|
||||
# 3. Ask Ollama which models are currently installed.
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
|
||||
resp.raise_for_status()
|
||||
installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
|
||||
raw_installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
|
||||
installed: set[str] = raw_installed | {
|
||||
n.removesuffix(":latest") for n in raw_installed if n.endswith(":latest")
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("Could not reach Ollama to check installed models", exc_info=True)
|
||||
return
|
||||
|
||||
# 3. Warm only the intersection (installed AND user-preferred).
|
||||
for model in user_models:
|
||||
base = model.removesuffix(":latest")
|
||||
if model in installed or f"{base}:latest" in installed or base in installed:
|
||||
await _warm_model(model)
|
||||
else:
|
||||
logger.info(
|
||||
"User-preferred model '%s' is not installed; skipping warm-up "
|
||||
"(install it via Settings → Models to enable auto-warm)",
|
||||
model,
|
||||
)
|
||||
# 4. Pull any user-configured models that are missing.
|
||||
for model in all_models:
|
||||
if model not in installed:
|
||||
logger.info("User-configured model '%s' not installed; pulling...", model)
|
||||
await _pull_model(model)
|
||||
installed.add(model)
|
||||
|
||||
# 5. Warm each unique chat model, then prime KV cache per user.
|
||||
warmed: set[str] = set()
|
||||
for user_id_val, model in user_chat_models:
|
||||
if model in installed:
|
||||
if model not in warmed:
|
||||
await _warm_model(model)
|
||||
warmed.add(model)
|
||||
await _prime_kv_cache(user_id_val, model)
|
||||
|
||||
async def _pull_model(model: str, warm: bool = False) -> None:
|
||||
try:
|
||||
@@ -241,10 +293,11 @@ def create_app() -> Quart:
|
||||
if warm:
|
||||
await _warm_model(model)
|
||||
|
||||
# Warm user-preferred chat models that are already installed.
|
||||
# Also ensure the embedding model is pulled (no warm needed).
|
||||
asyncio.create_task(_warm_user_models())
|
||||
# Ensure system-default models are present, then pull/warm user-configured ones.
|
||||
asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=True))
|
||||
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
|
||||
asyncio.create_task(_pull_model(Config.OLLAMA_BACKGROUND_MODEL, warm=False))
|
||||
asyncio.create_task(_warm_user_models())
|
||||
|
||||
# After models are pulled, backfill embeddings for existing notes.
|
||||
# Runs in the background so it never blocks the server from accepting requests.
|
||||
@@ -259,6 +312,16 @@ def create_app() -> Quart:
|
||||
await backfill_project_summaries()
|
||||
except Exception:
|
||||
logger.warning("Project summary backfill failed", exc_info=True)
|
||||
try:
|
||||
from fabledassistant.services.embeddings import backfill_rss_item_embeddings
|
||||
await backfill_rss_item_embeddings()
|
||||
except Exception:
|
||||
logger.warning("RSS embedding backfill failed", exc_info=True)
|
||||
try:
|
||||
from fabledassistant.services.embeddings import backfill_rss_article_content
|
||||
await backfill_rss_article_content()
|
||||
except Exception:
|
||||
logger.warning("RSS article content backfill failed", exc_info=True)
|
||||
|
||||
asyncio.create_task(_delayed_backfill())
|
||||
|
||||
@@ -266,6 +329,10 @@ def create_app() -> Quart:
|
||||
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
|
||||
await start_briefing_scheduler(asyncio.get_running_loop())
|
||||
|
||||
# Start event scheduler (reminders + CalDAV pull sync)
|
||||
from fabledassistant.services.event_scheduler import start_event_scheduler
|
||||
start_event_scheduler(asyncio.get_running_loop())
|
||||
|
||||
# Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var)
|
||||
from fabledassistant.services.stt import load_stt_model
|
||||
from fabledassistant.services.tts import load_tts_model
|
||||
@@ -276,6 +343,8 @@ def create_app() -> Quart:
|
||||
async def shutdown():
|
||||
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
|
||||
stop_briefing_scheduler()
|
||||
from fabledassistant.services.event_scheduler import stop_event_scheduler
|
||||
stop_event_scheduler()
|
||||
|
||||
@app.route("/")
|
||||
async def serve_index():
|
||||
|
||||
@@ -24,9 +24,14 @@ class Config:
|
||||
)
|
||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
|
||||
# KV cache context window for generation. Higher = more RAM usage but longer inputs/outputs.
|
||||
# 131072 is the practical maximum for most models. Lower this on RAM-constrained hosts.
|
||||
OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "65536"))
|
||||
# Lightweight model for background tasks (title generation, tag suggestions,
|
||||
# project summaries, RSS classification). Using a separate model keeps the
|
||||
# main model's KV cache intact between user messages, enabling prefix cache hits.
|
||||
OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "qwen2.5:3b")
|
||||
# KV cache context window for generation. Keep this as small as practical —
|
||||
# a larger context forces more KV cache into CPU RAM, drastically slowing prefill.
|
||||
# 16384 covers ~30+ message conversations with our system prompt comfortably.
|
||||
OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "16384"))
|
||||
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
||||
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
|
||||
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
|
||||
|
||||
@@ -41,3 +41,5 @@ from fabledassistant.models.notification import Notification # noqa: E402, F401
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401
|
||||
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
|
||||
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
|
||||
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
|
||||
from fabledassistant.models.rss_item_embedding import RssItemEmbedding # noqa: E402, F401
|
||||
|
||||
@@ -27,6 +27,8 @@ class Event(Base):
|
||||
caldav_uid: Mapped[str] = mapped_column(Text, default="")
|
||||
color: Mapped[str] = mapped_column(Text, default="")
|
||||
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reminder_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
reminder_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -51,6 +53,7 @@ class Event(Base):
|
||||
"location": self.location,
|
||||
"color": self.color,
|
||||
"recurrence": self.recurrence,
|
||||
"reminder_minutes": self.reminder_minutes,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ class Note(Base, TimestampMixin):
|
||||
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Entity type — 'note' (default), 'person', 'place', 'list'
|
||||
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
|
||||
# Structured metadata for entity types (person/place/list)
|
||||
# Named 'entity_meta' to avoid collision with SQLAlchemy's reserved 'metadata' attribute
|
||||
entity_meta: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
||||
@@ -59,12 +64,18 @@ class Note(Base, TimestampMixin):
|
||||
Index("ix_notes_user_id", "user_id"),
|
||||
Index("ix_notes_project_id", "project_id"),
|
||||
Index("ix_notes_milestone_id", "milestone_id"),
|
||||
Index("ix_notes_note_type", "note_type"),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_task(self) -> bool:
|
||||
return self.status is not None
|
||||
|
||||
@property
|
||||
def entity_type(self) -> str:
|
||||
"""Normalised type: 'note', 'person', 'place', or 'list'."""
|
||||
return self.note_type or "note"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
@@ -86,6 +97,8 @@ class Note(Base, TimestampMixin):
|
||||
else None
|
||||
),
|
||||
"is_task": self.is_task,
|
||||
"note_type": self.entity_type,
|
||||
"metadata": self.entity_meta or {},
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -29,11 +29,13 @@ class Project(Base, TimestampMixin):
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"goal": self.goal,
|
||||
"status": self.status,
|
||||
"color": self.color,
|
||||
"auto_summary": self.auto_summary,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class RssItemEmbedding(Base):
|
||||
"""Stores the embedding vector for an RSS item, used for semantic news search."""
|
||||
|
||||
__tablename__ = "rss_item_embeddings"
|
||||
|
||||
rss_item_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("rss_items.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
embedding: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import TimestampMixin
|
||||
|
||||
|
||||
class UserProfile(Base, TimestampMixin):
|
||||
__tablename__ = "user_profiles"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||
)
|
||||
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
job_title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
industry: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# novice / intermediate / expert — calibrates explanation depth
|
||||
expertise_level: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# concise / balanced / detailed
|
||||
response_style: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# casual / professional / technical
|
||||
tone: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
interests: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
# {days: ["Mon","Tue",...], start: "09:00", end: "17:00"}
|
||||
work_schedule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
# LLM-consolidated summary of learned preferences
|
||||
learned_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# [{date: "YYYY-MM-DD", bullets: "..."}, ...]
|
||||
observations_raw: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||
observations_updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"display_name": self.display_name or "",
|
||||
"job_title": self.job_title or "",
|
||||
"industry": self.industry or "",
|
||||
"expertise_level": self.expertise_level or "intermediate",
|
||||
"response_style": self.response_style or "balanced",
|
||||
"tone": self.tone or "casual",
|
||||
"interests": self.interests or [],
|
||||
"work_schedule": self.work_schedule or {},
|
||||
"learned_summary": self.learned_summary or "",
|
||||
"observations_count": len(self.observations_raw or []),
|
||||
"observations_updated_at": (
|
||||
self.observations_updated_at.isoformat()
|
||||
if self.observations_updated_at
|
||||
else None
|
||||
),
|
||||
}
|
||||
@@ -195,6 +195,10 @@ async def get_base_url_setting():
|
||||
async def update_base_url():
|
||||
data = await request.get_json()
|
||||
url = (data.get("base_url") or "").strip().rstrip("/")
|
||||
if url:
|
||||
scheme = url.split("://")[0].lower() if "://" in url else ""
|
||||
if scheme not in ("http", "https"):
|
||||
return jsonify({"error": "Base URL must use http or https"}), 400
|
||||
uid = get_current_user_id()
|
||||
await set_setting(uid, "base_url", url)
|
||||
await log_audit("base_url_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"base_url": url})
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy import select
|
||||
from fabledassistant.auth import login_required
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.models.rss_feed import RssFeed
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem
|
||||
from fabledassistant.services import rss as rss_svc
|
||||
from fabledassistant.services import weather as weather_svc
|
||||
from fabledassistant.services.briefing_conversations import (
|
||||
@@ -18,6 +18,9 @@ from fabledassistant.services.briefing_conversations import (
|
||||
list_briefing_conversations,
|
||||
post_message,
|
||||
)
|
||||
from fabledassistant.services.chat import add_message, get_conversation
|
||||
from fabledassistant.services.generation_buffer import create_buffer, get_buffer
|
||||
from fabledassistant.services.generation_task import run_generation
|
||||
from fabledassistant.services.settings import get_setting, set_settings_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -242,12 +245,30 @@ async def get_conversation_messages(conv_id: int):
|
||||
@briefing_bp.route("/trigger", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def manual_trigger():
|
||||
"""Dev/admin endpoint to manually trigger a briefing compilation."""
|
||||
"""Manually trigger a briefing compilation, including a fresh data refresh."""
|
||||
data = await request.get_json() or {}
|
||||
slot = data.get("slot", "compilation")
|
||||
if slot not in ("compilation", "morning", "midday", "afternoon"):
|
||||
return jsonify({"error": "invalid slot"}), 400
|
||||
|
||||
# Refresh external data first (mirrors what the scheduler does)
|
||||
try:
|
||||
from fabledassistant.services.rss import refresh_all_feeds
|
||||
config_raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
await refresh_all_feeds(g.user.id)
|
||||
for key, loc in config.get("locations", {}).items():
|
||||
if loc.get("lat") and loc.get("lon"):
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=g.user.id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Pre-trigger refresh failed for user %d", g.user.id, exc_info=True)
|
||||
|
||||
from fabledassistant.services.briefing_pipeline import run_compilation
|
||||
|
||||
model = await get_setting(g.user.id, "default_model", "")
|
||||
@@ -388,6 +409,7 @@ async def list_news():
|
||||
"title": r["title"],
|
||||
"url": r["url"],
|
||||
"snippet": (r["content"] or "")[:300],
|
||||
"content": r["content"] or "",
|
||||
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
|
||||
"topics": r["topics"] or [],
|
||||
"source": r["feed_title"],
|
||||
@@ -396,3 +418,91 @@ async def list_news():
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({"items": items, "offset": offset, "limit": limit})
|
||||
|
||||
|
||||
# ── Article Discuss ────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def discuss_article(item_id: int):
|
||||
"""Inject article content as a synthetic tool exchange and trigger generation."""
|
||||
data = await request.get_json() or {}
|
||||
conv_id = data.get("conv_id")
|
||||
if not conv_id:
|
||||
return jsonify({"error": "conv_id required"}), 400
|
||||
|
||||
uid = g.user.id
|
||||
|
||||
# Verify item belongs to user via feed ownership
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem)
|
||||
.join(RssFeed, RssFeed.id == RssItem.feed_id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
item = result.scalars().first()
|
||||
if item is None:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
# Verify conversation belongs to user
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
# Reject if generation already running
|
||||
if get_buffer(conv_id) is not None:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
article_content = await _fetch_full_article(item.url) or item.content or ""
|
||||
|
||||
# Store synthetic assistant message with read_article tool result
|
||||
synthetic_tool_calls = [{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}]
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
|
||||
# Store user message
|
||||
await add_message(conv_id, "user", "Please summarize and discuss this article.")
|
||||
|
||||
# Reload conversation with fresh messages to build history
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
assert conv is not None
|
||||
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title or "",
|
||||
"Please summarize and discuss this article.",
|
||||
think=True,
|
||||
))
|
||||
|
||||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
||||
|
||||
@@ -11,7 +11,6 @@ from fabledassistant.config import Config
|
||||
from fabledassistant.services.chat import (
|
||||
add_message,
|
||||
bulk_delete_conversations,
|
||||
cleanup_old_conversations,
|
||||
create_conversation,
|
||||
delete_conversation,
|
||||
get_conversation,
|
||||
@@ -40,14 +39,6 @@ async def list_conversations_route():
|
||||
uid = get_current_user_id()
|
||||
limit, offset = parse_pagination()
|
||||
conv_type = request.args.get("type", "chat")
|
||||
# Apply retention policy before returning list
|
||||
retention_str = await get_setting(uid, "chat_retention_days", "90")
|
||||
try:
|
||||
retention_days = int(retention_str)
|
||||
except (ValueError, TypeError):
|
||||
retention_days = 90
|
||||
if retention_days > 0:
|
||||
await cleanup_old_conversations(uid, retention_days)
|
||||
conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type)
|
||||
return jsonify({
|
||||
"conversations": conversations,
|
||||
@@ -171,8 +162,19 @@ async def send_message_route(conv_id: int):
|
||||
# Build history from existing messages (excluding system and the placeholder)
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role != "system":
|
||||
history.append({"role": msg.role, "content": msg.content})
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
||||
|
||||
@@ -502,3 +504,52 @@ async def delete_model_route():
|
||||
except Exception as e:
|
||||
logger.warning("Failed to delete model %s: %s", model_name, e)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@chat_bp.route("/from-article/<int:item_id>", methods=["POST"])
|
||||
@login_required
|
||||
async def create_conversation_from_article(item_id: int):
|
||||
"""Create a chat conversation seeded with an RSS article's content."""
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Message
|
||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||
|
||||
uid = get_current_user_id()
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(RssItem, RssFeed.title.label("feed_title"))
|
||||
.join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
if row is None:
|
||||
return jsonify({"error": "Article not found"}), 404
|
||||
|
||||
item, feed_title = row
|
||||
|
||||
conv_title = (item.title or "Article discussion")[:80]
|
||||
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
source = feed_title or "News"
|
||||
content_body = (await _fetch_full_article(item.url) if item.url else None) or (item.content or "").strip()
|
||||
seeded_text = f"**{source}**\n\n**{item.title}**"
|
||||
if content_body:
|
||||
seeded_text += f"\n\n{content_body}"
|
||||
if item.url:
|
||||
seeded_text += f"\n\nSource: {item.url}"
|
||||
|
||||
async with _async_session() as session:
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role="assistant",
|
||||
content=seeded_text,
|
||||
msg_metadata={"rss_item_ids": [item_id]},
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
|
||||
return jsonify({"conversation_id": conv.id}), 201
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Calendar events REST API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
@@ -11,6 +11,14 @@ import fabledassistant.services.events as events_svc
|
||||
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> datetime:
|
||||
"""Parse ISO 8601 datetime string, ensuring UTC-awareness."""
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _get_current_user_id() -> int:
|
||||
return g.user.id
|
||||
|
||||
@@ -23,8 +31,8 @@ async def list_events():
|
||||
if not date_from_str or not date_to_str:
|
||||
return jsonify({"error": "from and to query params are required"}), 400
|
||||
try:
|
||||
date_from = datetime.fromisoformat(date_from_str)
|
||||
date_to = datetime.fromisoformat(date_to_str)
|
||||
date_from = _parse_dt(date_from_str)
|
||||
date_to = _parse_dt(date_to_str)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
events = await events_svc.list_events(
|
||||
@@ -32,7 +40,7 @@ async def list_events():
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
return jsonify([e.to_dict() for e in events])
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@events_bp.post("")
|
||||
@@ -42,8 +50,8 @@ async def create_event():
|
||||
if not data.get("title") or not data.get("start_dt"):
|
||||
return jsonify({"error": "title and start_dt are required"}), 400
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(data["start_dt"])
|
||||
end_dt = datetime.fromisoformat(data["end_dt"]) if data.get("end_dt") else None
|
||||
start_dt = _parse_dt(data["start_dt"])
|
||||
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
event = await events_svc.create_event(
|
||||
@@ -57,6 +65,7 @@ async def create_event():
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
reminder_minutes=data.get("reminder_minutes"),
|
||||
)
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
@@ -84,15 +93,19 @@ async def update_event(event_id: int):
|
||||
for bool_field in ("all_day",):
|
||||
if bool_field in data:
|
||||
fields[bool_field] = data[bool_field]
|
||||
for int_field in ("project_id",):
|
||||
for int_field in ("project_id", "reminder_minutes"):
|
||||
if int_field in data:
|
||||
fields[int_field] = data[int_field]
|
||||
for dt_field in ("start_dt", "end_dt"):
|
||||
if dt_field in data and data[dt_field]:
|
||||
try:
|
||||
fields[dt_field] = datetime.fromisoformat(data[dt_field])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
||||
if dt_field in data:
|
||||
if data[dt_field] is None:
|
||||
# Explicit null clears the field (e.g. removing end_dt)
|
||||
fields[dt_field] = None
|
||||
elif data[dt_field]:
|
||||
try:
|
||||
fields[dt_field] = _parse_dt(data[dt_field])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
@@ -117,3 +130,12 @@ async def delete_event(event_id: int):
|
||||
event_id=event_id,
|
||||
)
|
||||
return "", 204
|
||||
|
||||
|
||||
@events_bp.post("/sync")
|
||||
@login_required
|
||||
async def sync_caldav():
|
||||
"""Trigger a CalDAV pull sync for the current user."""
|
||||
from fabledassistant.services.caldav_sync import sync_user_events
|
||||
result = await sync_user_events(user_id=_get_current_user_id())
|
||||
return jsonify(result)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Unified Knowledge endpoint — notes, people, places, lists in one queryable feed."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.routes.utils import parse_pagination
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
||||
|
||||
_VALID_TYPES = {"note", "person", "place", "list", "task"}
|
||||
_VALID_SORTS = {"modified", "created", "alpha", "type"}
|
||||
|
||||
|
||||
@knowledge_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_knowledge():
|
||||
"""Return paginated knowledge objects with optional filtering.
|
||||
|
||||
Query params:
|
||||
type — one of note|person|place|list (omit for all, excludes tasks)
|
||||
tags — comma-separated tag filter (AND logic)
|
||||
sort — modified|created|alpha|type (default: modified)
|
||||
q — search query (semantic when provided, keyword fallback)
|
||||
page — 1-based page number (default 1)
|
||||
per_page — items per page (default 24, max 100)
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
note_type = request.args.get("type", "").strip().lower() or None
|
||||
tags_raw = request.args.get("tags", "").strip()
|
||||
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
|
||||
sort = request.args.get("sort", "modified").strip().lower()
|
||||
q = request.args.get("q", "").strip() or None
|
||||
|
||||
if note_type and note_type not in _VALID_TYPES:
|
||||
return jsonify({"error": f"Invalid type. Must be one of: {', '.join(sorted(_VALID_TYPES))}"}), 400
|
||||
if sort not in _VALID_SORTS:
|
||||
sort = "modified"
|
||||
|
||||
limit, offset = parse_pagination(default_limit=24, max_limit=100)
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
|
||||
from fabledassistant.services.knowledge import query_knowledge
|
||||
items, total = await query_knowledge(
|
||||
user_id=uid,
|
||||
note_type=note_type,
|
||||
tags=tags,
|
||||
sort=sort,
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": limit,
|
||||
"pages": max(1, (total + limit - 1) // limit),
|
||||
})
|
||||
|
||||
|
||||
@knowledge_bp.route("/ids", methods=["GET"])
|
||||
@login_required
|
||||
async def list_knowledge_ids():
|
||||
"""Return note IDs only (cheap) for the two-tier pagination feed.
|
||||
|
||||
Same filter params as GET /api/knowledge.
|
||||
Additional params: limit (default 100, max 200), offset (default 0).
|
||||
Returns {ids, total, has_more}.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
note_type = request.args.get("type", "").strip().lower() or None
|
||||
tags_raw = request.args.get("tags", "").strip()
|
||||
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
|
||||
sort = request.args.get("sort", "modified").strip().lower()
|
||||
q = request.args.get("q", "").strip() or None
|
||||
if sort not in _VALID_SORTS:
|
||||
sort = "modified"
|
||||
try:
|
||||
limit = min(int(request.args.get("limit", 100)), 200)
|
||||
offset = max(0, int(request.args.get("offset", 0)))
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid limit or offset"}), 400
|
||||
|
||||
if note_type and note_type not in _VALID_TYPES:
|
||||
return jsonify({"error": "Invalid type"}), 400
|
||||
|
||||
from fabledassistant.services.knowledge import query_knowledge_ids
|
||||
ids, total = await query_knowledge_ids(
|
||||
user_id=uid, note_type=note_type, tags=tags,
|
||||
sort=sort, q=q, limit=limit, offset=offset,
|
||||
)
|
||||
return jsonify({"ids": ids, "total": total, "has_more": (offset + len(ids)) < total})
|
||||
|
||||
|
||||
@knowledge_bp.route("/batch", methods=["GET"])
|
||||
@login_required
|
||||
async def get_knowledge_batch():
|
||||
"""Fetch full items for a comma-separated list of IDs (max 100).
|
||||
|
||||
Returns {items: [...]} in the order of the requested IDs.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
ids_raw = request.args.get("ids", "").strip()
|
||||
if not ids_raw:
|
||||
return jsonify({"items": []})
|
||||
try:
|
||||
ids = [int(x) for x in ids_raw.split(",") if x.strip()]
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid IDs"}), 400
|
||||
if len(ids) > 100:
|
||||
return jsonify({"error": "Too many IDs (max 100)"}), 400
|
||||
|
||||
from fabledassistant.services.knowledge import get_knowledge_by_ids
|
||||
items = await get_knowledge_by_ids(uid, ids)
|
||||
return jsonify({"items": items})
|
||||
|
||||
|
||||
@knowledge_bp.route("/tags", methods=["GET"])
|
||||
@login_required
|
||||
async def list_knowledge_tags():
|
||||
"""Return all tags used across knowledge objects (excludes tasks)."""
|
||||
uid = get_current_user_id()
|
||||
note_type = request.args.get("type", "").strip().lower() or None
|
||||
|
||||
from fabledassistant.services.knowledge import get_knowledge_tags
|
||||
tags = await get_knowledge_tags(uid, note_type=note_type)
|
||||
return jsonify({"tags": tags})
|
||||
|
||||
|
||||
@knowledge_bp.route("/counts", methods=["GET"])
|
||||
@login_required
|
||||
async def get_knowledge_counts():
|
||||
"""Return per-type counts — used by the sidebar to show item counts."""
|
||||
uid = get_current_user_id()
|
||||
tags_raw = request.args.get("tags", "").strip()
|
||||
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None
|
||||
|
||||
from fabledassistant.services.knowledge import get_knowledge_counts as _counts
|
||||
counts = await _counts(uid, tags=tags)
|
||||
return jsonify(counts)
|
||||
@@ -5,16 +5,17 @@ from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.routes.utils import not_found, parse_pagination
|
||||
from fabledassistant.services.access import can_write_project
|
||||
from fabledassistant.services.milestones import (
|
||||
create_milestone,
|
||||
delete_milestone,
|
||||
get_milestone,
|
||||
get_milestone_in_project,
|
||||
get_milestone_progress,
|
||||
list_milestones,
|
||||
update_milestone,
|
||||
)
|
||||
from fabledassistant.services.notes import list_notes
|
||||
from fabledassistant.services.projects import get_project
|
||||
from fabledassistant.services.projects import get_project_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,22 +32,25 @@ async def _milestone_dict(m) -> dict:
|
||||
@login_required
|
||||
async def list_milestones_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
project, _ = result
|
||||
# List milestones using the project owner's uid so ownership filter matches
|
||||
owner_uid = project.user_id or uid
|
||||
status = request.args.get("status")
|
||||
milestones = await list_milestones(uid, project_id, status=status)
|
||||
result = [await _milestone_dict(m) for m in milestones]
|
||||
return jsonify({"milestones": result})
|
||||
milestones = await list_milestones(owner_uid, project_id, status=status)
|
||||
return jsonify({"milestones": [await _milestone_dict(m) for m in milestones]})
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones", methods=["POST"])
|
||||
@login_required
|
||||
async def create_milestone_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
if not await can_write_project(uid, project_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
data = await request.get_json()
|
||||
if not data.get("title"):
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
@@ -68,8 +72,10 @@ async def create_milestone_route(project_id: int):
|
||||
@login_required
|
||||
async def get_milestone_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
milestone = await get_milestone_in_project(project_id, milestone_id)
|
||||
if milestone is None:
|
||||
return not_found("Milestone")
|
||||
return jsonify(await _milestone_dict(milestone))
|
||||
|
||||
@@ -78,13 +84,19 @@ async def get_milestone_route(project_id: int, milestone_id: int):
|
||||
@login_required
|
||||
async def update_milestone_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
if not await can_write_project(uid, project_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
milestone = await get_milestone_in_project(project_id, milestone_id)
|
||||
if milestone is None:
|
||||
return not_found("Milestone")
|
||||
data = await request.get_json()
|
||||
allowed = {"title", "description", "status", "order_index"}
|
||||
fields = {k: v for k, v in data.items() if k in allowed}
|
||||
updated = await update_milestone(uid, milestone_id, **fields)
|
||||
if "status" in fields and fields["status"] not in ("active", "done"):
|
||||
return jsonify({"error": "status must be 'active' or 'done'"}), 400
|
||||
updated = await update_milestone(milestone.user_id, milestone_id, **fields)
|
||||
if updated is None:
|
||||
return not_found("Milestone")
|
||||
return jsonify(await _milestone_dict(updated))
|
||||
@@ -94,10 +106,14 @@ async def update_milestone_route(project_id: int, milestone_id: int):
|
||||
@login_required
|
||||
async def delete_milestone_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
if not await can_write_project(uid, project_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
milestone = await get_milestone_in_project(project_id, milestone_id)
|
||||
if milestone is None:
|
||||
return not_found("Milestone")
|
||||
deleted = await delete_milestone(uid, milestone_id)
|
||||
deleted = await delete_milestone(milestone.user_id, milestone_id)
|
||||
if not deleted:
|
||||
return not_found("Milestone")
|
||||
return "", 204
|
||||
@@ -107,13 +123,15 @@ async def delete_milestone_route(project_id: int, milestone_id: int):
|
||||
@login_required
|
||||
async def get_milestone_tasks_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
milestone = await get_milestone_in_project(project_id, milestone_id)
|
||||
if milestone is None:
|
||||
return not_found("Milestone")
|
||||
status_filter = request.args.get("status")
|
||||
limit, offset = parse_pagination(default_limit=100)
|
||||
notes, total = await list_notes(
|
||||
uid,
|
||||
milestone.user_id,
|
||||
is_task=True,
|
||||
status=status_filter,
|
||||
milestone_id=milestone_id,
|
||||
|
||||
@@ -104,18 +104,26 @@ async def create_note_route():
|
||||
if proj:
|
||||
project_id = proj.id
|
||||
|
||||
note = await create_note(
|
||||
uid,
|
||||
title=data.get("title", ""),
|
||||
body=body,
|
||||
tags=tags,
|
||||
parent_id=data.get("parent_id"),
|
||||
project_id=project_id,
|
||||
milestone_id=data.get("milestone_id"),
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
)
|
||||
note_type = data.get("note_type", "note")
|
||||
entity_meta = data.get("metadata") or None
|
||||
|
||||
try:
|
||||
note = await create_note(
|
||||
uid,
|
||||
title=data.get("title", ""),
|
||||
body=body,
|
||||
tags=tags,
|
||||
parent_id=data.get("parent_id"),
|
||||
project_id=project_id,
|
||||
milestone_id=data.get("milestone_id"),
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
note_type=note_type,
|
||||
entity_meta=entity_meta,
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
||||
@@ -207,9 +215,11 @@ async def update_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
|
||||
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "metadata" in data:
|
||||
fields["entity_meta"] = data["metadata"] or None
|
||||
|
||||
if "due_date" in data:
|
||||
if data["due_date"]:
|
||||
@@ -222,7 +232,10 @@ async def update_note_route(note_id: int):
|
||||
|
||||
if "tags" in data:
|
||||
fields["tags"] = data["tags"]
|
||||
note = await update_note(uid, note_id, **fields)
|
||||
try:
|
||||
note = await update_note(uid, note_id, **fields)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
@@ -237,19 +250,30 @@ async def patch_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
|
||||
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "metadata" in data:
|
||||
fields["entity_meta"] = data["metadata"] or None
|
||||
if "due_date" in data:
|
||||
result = parse_iso_date(data.get("due_date"), "due_date")
|
||||
if isinstance(result, tuple):
|
||||
return result
|
||||
fields["due_date"] = result
|
||||
if data["due_date"]:
|
||||
result = parse_iso_date(data["due_date"], "due_date")
|
||||
if isinstance(result, tuple):
|
||||
return result
|
||||
fields["due_date"] = result
|
||||
else:
|
||||
fields["due_date"] = None
|
||||
if "tags" in data:
|
||||
fields["tags"] = data["tags"]
|
||||
note = await update_note(uid, note_id, **fields)
|
||||
try:
|
||||
note = await update_note(uid, note_id, **fields)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.services.user_profile import (
|
||||
VALID_EXPERTISE,
|
||||
VALID_STYLES,
|
||||
VALID_TONES,
|
||||
clear_learned_data,
|
||||
consolidate_observations,
|
||||
get_profile,
|
||||
update_profile,
|
||||
)
|
||||
|
||||
profile_bp = Blueprint("profile", __name__, url_prefix="/api/profile")
|
||||
|
||||
|
||||
@profile_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def get_profile_route():
|
||||
uid = get_current_user_id()
|
||||
profile = await get_profile(uid)
|
||||
return jsonify(profile.to_dict())
|
||||
|
||||
|
||||
@profile_bp.route("", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_profile_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Expected a JSON object"}), 400
|
||||
|
||||
if "expertise_level" in data and data["expertise_level"] not in VALID_EXPERTISE:
|
||||
return jsonify({"error": f"expertise_level must be one of {sorted(VALID_EXPERTISE)}"}), 400
|
||||
if "response_style" in data and data["response_style"] not in VALID_STYLES:
|
||||
return jsonify({"error": f"response_style must be one of {sorted(VALID_STYLES)}"}), 400
|
||||
if "tone" in data and data["tone"] not in VALID_TONES:
|
||||
return jsonify({"error": f"tone must be one of {sorted(VALID_TONES)}"}), 400
|
||||
if "interests" in data and not isinstance(data["interests"], list):
|
||||
return jsonify({"error": "interests must be an array"}), 400
|
||||
if "work_schedule" in data and not isinstance(data["work_schedule"], dict):
|
||||
return jsonify({"error": "work_schedule must be an object"}), 400
|
||||
|
||||
profile = await update_profile(uid, data)
|
||||
return jsonify(profile.to_dict())
|
||||
|
||||
|
||||
@profile_bp.route("/consolidate", methods=["POST"])
|
||||
@login_required
|
||||
async def trigger_consolidate():
|
||||
uid = get_current_user_id()
|
||||
summary = await consolidate_observations(uid)
|
||||
return jsonify({"status": "ok", "learned_summary": summary})
|
||||
|
||||
|
||||
@profile_bp.route("/observations", methods=["DELETE"])
|
||||
@login_required
|
||||
async def clear_observations():
|
||||
uid = get_current_user_id()
|
||||
await clear_learned_data(uid)
|
||||
return jsonify({"status": "ok"})
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Project management routes."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
@@ -27,7 +28,19 @@ projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
|
||||
async def list_projects_route():
|
||||
uid = get_current_user_id()
|
||||
status = request.args.get("status")
|
||||
include_summary = request.args.get("include_summary", "").lower() in ("1", "true")
|
||||
projects = await list_projects_for_user(uid, status=status)
|
||||
if include_summary:
|
||||
# Fetch all summaries in parallel — one backend pass instead of N+1 frontend calls
|
||||
async def _attach(project_dict: dict) -> dict:
|
||||
try:
|
||||
owner_uid = project_dict.get("user_id") or uid # user_id now in to_dict()
|
||||
summary = await get_project_summary(owner_uid, project_dict["id"])
|
||||
project_dict["summary"] = summary
|
||||
except Exception:
|
||||
pass
|
||||
return project_dict
|
||||
projects = list(await asyncio.gather(*[_attach(p) for p in projects]))
|
||||
return jsonify({"projects": projects})
|
||||
|
||||
|
||||
@@ -39,8 +52,8 @@ 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", "archived"):
|
||||
return jsonify({"error": "status must be 'active' or 'archived'"}), 400
|
||||
if status not in ("active", "completed", "archived"):
|
||||
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
||||
project = await create_project(
|
||||
uid,
|
||||
title=data["title"],
|
||||
@@ -76,6 +89,8 @@ async def update_project_route(project_id: int):
|
||||
data = await request.get_json()
|
||||
allowed = {"title", "description", "goal", "status", "color"}
|
||||
fields = {k: v for k, v in data.items() if k in allowed}
|
||||
if "status" in fields and fields["status"] not in ("active", "completed", "archived"):
|
||||
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
||||
project = await update_project(uid, project_id, **fields)
|
||||
if project is None:
|
||||
return not_found("Project")
|
||||
|
||||
@@ -3,9 +3,9 @@ import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.auth import login_required, get_current_user_id, admin_required
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.push import delete_subscription, save_subscription, vapid_enabled
|
||||
from fabledassistant.services.push import delete_subscription, regenerate_vapid_keys, save_subscription, vapid_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,3 +42,13 @@ async def unsubscribe():
|
||||
return jsonify({"error": "endpoint is required"}), 400
|
||||
await delete_subscription(uid, endpoint)
|
||||
return "", 204
|
||||
|
||||
|
||||
@push_bp.route("/reset-vapid", methods=["POST"])
|
||||
@admin_required
|
||||
async def reset_vapid():
|
||||
"""Regenerate VAPID keys and clear all push subscriptions."""
|
||||
ok = await regenerate_vapid_keys()
|
||||
if ok:
|
||||
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY}), 200
|
||||
return jsonify({"error": "Key regeneration failed"}), 500
|
||||
|
||||
@@ -1,77 +1,39 @@
|
||||
"""Quick-capture endpoint for mobile/external clients.
|
||||
|
||||
POST /api/quick-capture — classifies natural-language text and creates the
|
||||
appropriate item (note, task, calendar event, todo) in a single synchronous
|
||||
request. No SSE, no conversation ID, no streaming.
|
||||
POST /api/quick-capture — sends text through the main LLM tool-calling pipeline
|
||||
and returns a single synchronous JSON response. No SSE, no conversation ID.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.intent import classify_capture_intent
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.generation_task import _should_think
|
||||
from fabledassistant.services.llm import stream_chat_with_tools
|
||||
from fabledassistant.services.tools import execute_tool, get_tools_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture")
|
||||
|
||||
# Tools offered to the quick-capture classifier. Excludes destructive ops
|
||||
# (delete_*) and read-only queries — worst-case fallback is a plain note.
|
||||
# Tools offered to the quick-capture endpoint. Excludes destructive ops,
|
||||
# read-only queries, and conversational-only tools.
|
||||
_CAPTURE_TOOL_NAMES = {"create_note", "create_task", "create_event", "update_note", "research_topic"}
|
||||
|
||||
_NOTE_PROCESS_PROMPT = """\
|
||||
You are a note-taking assistant. The user has sent a quick-capture snippet. \
|
||||
Transform it into a well-formed note.
|
||||
|
||||
Respond with ONLY a JSON object — no other text, no code fences:
|
||||
{{"title": "short descriptive title", "body": "note content in markdown"}}
|
||||
|
||||
Rules:
|
||||
- title: 3–8 words, a genuine summary — do NOT copy the input verbatim
|
||||
- body: process the input thoughtfully:
|
||||
- Lists of items → formatted bullet list
|
||||
- A stream-of-thought or observation → clean prose, lightly organised
|
||||
- Raw notes or fragments → organised paragraphs with a brief intro line
|
||||
- URLs → include the URL and a one-sentence description of what it points to
|
||||
- Preserve ALL information from the original; do not invent new facts
|
||||
- Use markdown formatting (##, -, **, etc.) where it aids readability
|
||||
- Keep it concise — do not pad with filler"""
|
||||
|
||||
|
||||
async def _process_note(text: str, model: str) -> tuple[str, str]:
|
||||
"""Use the main model to transform raw capture text into a title + body.
|
||||
|
||||
Returns (title, body). Falls back to (truncated text, full text) on any failure.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": _NOTE_PROCESS_PROMPT},
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
try:
|
||||
raw = await generate_completion(messages, model, max_tokens=1024, num_ctx=4096)
|
||||
raw = raw.strip()
|
||||
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||
raw = re.sub(r"\s*```$", "", raw).strip()
|
||||
parsed = json.loads(raw)
|
||||
title = str(parsed.get("title", "")).strip() or text[:60]
|
||||
body = str(parsed.get("body", "")).strip() or text
|
||||
return title, body
|
||||
except Exception:
|
||||
logger.warning("Note processing LLM call failed, using raw text", exc_info=True)
|
||||
fallback_title = text if len(text) <= 80 else text[:77] + "..."
|
||||
return fallback_title, text
|
||||
_SYSTEM_PROMPT = """\
|
||||
Today is {today}. You are a quick-capture assistant. The user has sent a short \
|
||||
snippet from their mobile device. Create the appropriate item — note, task, or \
|
||||
calendar event — using the available tools. Always call a tool; never reply \
|
||||
conversationally."""
|
||||
|
||||
|
||||
@quick_capture_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def quick_capture_route():
|
||||
"""Classify text and create the appropriate item, returning a single JSON response."""
|
||||
"""Classify text via native tool-calling and create the appropriate item."""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json(silent=True) or {}
|
||||
text = data.get("text", "").strip()
|
||||
@@ -81,26 +43,37 @@ async def quick_capture_route():
|
||||
from fabledassistant.services.settings import get_setting
|
||||
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
# Build tool list for this user, then restrict to capture-only operations.
|
||||
all_tools = await get_tools_for_user(uid)
|
||||
capture_tools = [
|
||||
t for t in all_tools if t.get("function", {}).get("name") in _CAPTURE_TOOL_NAMES
|
||||
]
|
||||
|
||||
intent = await classify_capture_intent(text, capture_tools, model)
|
||||
messages = [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT.format(today=date.today().isoformat())},
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
|
||||
if intent.should_execute:
|
||||
# research_topic bypasses execute_tool — run the pipeline directly
|
||||
if intent.tool_name == "research_topic" and Config.searxng_enabled():
|
||||
think = _should_think(text, think_requested=True)
|
||||
|
||||
tool_calls: list[dict] = []
|
||||
try:
|
||||
async for chunk in stream_chat_with_tools(messages, model, tools=capture_tools, think=think, num_ctx=4096):
|
||||
if chunk.type == "tool_calls" and chunk.tool_calls:
|
||||
tool_calls = chunk.tool_calls
|
||||
except Exception:
|
||||
logger.warning("Quick-capture LLM call failed for uid=%d", uid, exc_info=True)
|
||||
|
||||
if tool_calls:
|
||||
tc = tool_calls[0]
|
||||
tool_name = tc.get("function", {}).get("name", "")
|
||||
arguments = tc.get("function", {}).get("arguments", {})
|
||||
|
||||
if tool_name == "research_topic" and Config.searxng_enabled():
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
|
||||
topic = intent.arguments.get("topic", text)
|
||||
topic = arguments.get("topic", text)
|
||||
try:
|
||||
note = await run_research_pipeline(topic, uid, model)
|
||||
logger.info(
|
||||
"Quick-capture uid=%d: research note id=%d '%s'",
|
||||
uid, note.id, note.title,
|
||||
)
|
||||
logger.info("Quick-capture uid=%d: research note id=%d '%s'", uid, note.id, note.title)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"type": "note",
|
||||
@@ -108,48 +81,27 @@ async def quick_capture_route():
|
||||
"data": {"id": note.id, "title": note.title},
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.exception("Quick-capture research failed for topic: %s", topic)
|
||||
logger.exception("Quick-capture research failed: %s", topic)
|
||||
return jsonify({"error": f"Research failed: {exc}"}), 500
|
||||
|
||||
# For notes, run a second LLM pass to generate a proper title and
|
||||
# well-formed body rather than using the raw capture text verbatim.
|
||||
if intent.tool_name == "create_note":
|
||||
title, body = await _process_note(text, model)
|
||||
intent.arguments["title"] = title
|
||||
intent.arguments["body"] = body
|
||||
|
||||
result = await execute_tool(uid, intent.tool_name, intent.arguments)
|
||||
result = await execute_tool(uid, tool_name, arguments)
|
||||
if result.get("success"):
|
||||
item_type = result.get("type", "note")
|
||||
title = (result.get("data") or {}).get("title", "")
|
||||
logger.info(
|
||||
"Quick-capture uid=%d: %s '%s' via intent '%s'",
|
||||
uid, item_type, title, intent.tool_name,
|
||||
)
|
||||
logger.info("Quick-capture uid=%d: %s '%s'", uid, item_type, title)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"type": item_type,
|
||||
"message": f"{item_type.capitalize()}: {title}",
|
||||
"data": result.get("data"),
|
||||
})
|
||||
logger.warning(
|
||||
"Quick-capture uid=%d: tool '%s' returned failure: %s",
|
||||
uid, intent.tool_name, result.get("error"),
|
||||
)
|
||||
# Fall through to plain-note fallback
|
||||
logger.warning("Quick-capture uid=%d: tool '%s' failed: %s", uid, tool_name, result.get("error"))
|
||||
|
||||
# Fallback: classify_capture_intent returned no-tool (e.g. LLM parse failure).
|
||||
# Still process the text through the note enrichment pass.
|
||||
fallback_title, fallback_body = await _process_note(text, model)
|
||||
|
||||
result = await execute_tool(
|
||||
uid, "create_note", {"title": fallback_title, "body": fallback_body}
|
||||
)
|
||||
# Fallback: create a plain note with the raw text
|
||||
result = await execute_tool(uid, "create_note", {"title": text[:80], "body": text})
|
||||
if result.get("success"):
|
||||
title = (result.get("data") or {}).get("title", "")
|
||||
logger.info(
|
||||
"Quick-capture uid=%d: fallback note created '%s'", uid, title
|
||||
)
|
||||
logger.info("Quick-capture uid=%d: fallback note '%s'", uid, title)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"type": "note",
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
|
||||
from fabledassistant.services.llm import get_installed_models, _is_private_url
|
||||
from fabledassistant.services.settings import delete_setting, get_all_settings, set_settings_batch
|
||||
from fabledassistant.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _prime_kv_cache_bg(user_id: int, model: str) -> None:
|
||||
"""Fire-and-forget: prime Ollama's KV cache with the user's system prompt."""
|
||||
import httpx
|
||||
from fabledassistant.services.llm import build_context, pick_num_ctx
|
||||
try:
|
||||
messages, _ = await build_context(
|
||||
user_id=user_id,
|
||||
history=[],
|
||||
current_note_id=None,
|
||||
user_message=" ",
|
||||
)
|
||||
num_ctx = pick_num_ctx(messages)
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/chat",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"num_predict": 1, "num_ctx": num_ctx},
|
||||
"keep_alive": "2h",
|
||||
},
|
||||
)
|
||||
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
|
||||
except Exception:
|
||||
logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True)
|
||||
|
||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
||||
|
||||
@@ -32,10 +65,10 @@ async def update_settings_route():
|
||||
if installed and model not in installed:
|
||||
return jsonify({"error": f"Model '{model}' is not installed"}), 400
|
||||
|
||||
# Empty string for default_model means "reset to system default".
|
||||
# Empty string for model keys means "reset to system default".
|
||||
# Delete the DB row so get_setting() falls back to Config defaults
|
||||
# rather than returning "" and breaking model resolution everywhere.
|
||||
_MODEL_KEYS = frozenset({"default_model"})
|
||||
_MODEL_KEYS = frozenset({"default_model", "background_model"})
|
||||
to_save = {}
|
||||
for k, v in data.items():
|
||||
str_v = str(v)
|
||||
@@ -46,6 +79,22 @@ async def update_settings_route():
|
||||
|
||||
if to_save:
|
||||
await set_settings_batch(uid, to_save)
|
||||
|
||||
# When timezone changes, live-patch the briefing scheduler immediately
|
||||
if "user_timezone" in to_save:
|
||||
import json as _json
|
||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||
config_raw = await get_setting(uid, "briefing_config", "{}")
|
||||
try:
|
||||
config = _json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
except Exception:
|
||||
config = {}
|
||||
if config.get("enabled"):
|
||||
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
|
||||
|
||||
if "default_model" in to_save and to_save["default_model"]:
|
||||
asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"]))
|
||||
|
||||
settings = await get_all_settings(uid)
|
||||
return jsonify(settings)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from quart import Blueprint, jsonify, request
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.models.note import TaskPriority, TaskStatus
|
||||
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||
from fabledassistant.services.access import can_write_note
|
||||
from fabledassistant.services.embeddings import upsert_note_embedding
|
||||
from fabledassistant.services.notes import (
|
||||
create_note,
|
||||
@@ -154,6 +155,13 @@ async def get_task_route(task_id: int):
|
||||
@login_required
|
||||
async def update_task_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, task_id)
|
||||
if result is None:
|
||||
return not_found("Task")
|
||||
task_note, _ = result
|
||||
if not await can_write_note(uid, task_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title",):
|
||||
@@ -198,12 +206,12 @@ async def update_task_route(task_id: int):
|
||||
if recurrence_rule is not _UNSET:
|
||||
fields["recurrence_rule"] = recurrence_rule
|
||||
|
||||
task = await update_note(uid, task_id, **fields)
|
||||
task = await update_note(task_note.user_id, task_id, **fields)
|
||||
if task is None:
|
||||
return not_found("Task")
|
||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
|
||||
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
|
||||
return jsonify(task.to_dict())
|
||||
|
||||
|
||||
@@ -211,17 +219,23 @@ async def update_task_route(task_id: int):
|
||||
@login_required
|
||||
async def patch_task_status(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, task_id)
|
||||
if result is None:
|
||||
return not_found("Task")
|
||||
task_note, _ = result
|
||||
if not await can_write_note(uid, task_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
|
||||
data = await request.get_json()
|
||||
status_val = data.get("status")
|
||||
if not status_val:
|
||||
return jsonify({"error": "status is required"}), 400
|
||||
|
||||
try:
|
||||
TaskStatus(status_val)
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid status: {status_val}"}), 400
|
||||
|
||||
task = await update_note(uid, task_id, status=status_val)
|
||||
task = await update_note(task_note.user_id, task_id, status=status_val)
|
||||
if task is None:
|
||||
return not_found("Task")
|
||||
return jsonify(task.to_dict())
|
||||
@@ -252,7 +266,13 @@ async def recurrence_preview_route(task_id: int):
|
||||
@login_required
|
||||
async def delete_task_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_note(uid, task_id)
|
||||
result = await get_note_for_user(uid, task_id)
|
||||
if result is None:
|
||||
return not_found("Task")
|
||||
task_note, _ = result
|
||||
if not await can_write_note(uid, task_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
deleted = await delete_note(task_note.user_id, task_id)
|
||||
if not deleted:
|
||||
return not_found("Task")
|
||||
return "", 204
|
||||
|
||||
@@ -80,10 +80,12 @@ async def transcribe_audio():
|
||||
return jsonify({"error": "Audio file too large (max 25 MB)"}), 413
|
||||
|
||||
mime_type = audio_file.content_type or "audio/webm"
|
||||
form = await request.form
|
||||
context = (form.get("context") or "").strip() or None
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
transcript = await transcribe(audio_bytes, mime_type)
|
||||
transcript = await transcribe(audio_bytes, mime_type, initial_prompt=context)
|
||||
except Exception:
|
||||
logger.exception("STT transcription failed")
|
||||
return jsonify({"error": "Transcription failed"}), 500
|
||||
@@ -117,7 +119,12 @@ async def synthesise_speech():
|
||||
if not text:
|
||||
return jsonify({"error": "text is required"}), 400
|
||||
|
||||
if len(text) > 8000:
|
||||
char_count = len(text)
|
||||
if char_count > 8000:
|
||||
logger.warning(
|
||||
"TTS request rejected: text too long (%d chars, limit 8000). Preview: %r",
|
||||
char_count, text[:120],
|
||||
)
|
||||
return jsonify({"error": "text too long (max 8000 characters)"}), 400
|
||||
|
||||
voice = str(data.get("voice", "af_heart"))
|
||||
@@ -126,11 +133,57 @@ async def synthesise_speech():
|
||||
except (TypeError, ValueError):
|
||||
speed = 1.0
|
||||
|
||||
voice_blend = data.get("voice_blend")
|
||||
if not isinstance(voice_blend, list) or len(voice_blend) < 2:
|
||||
voice_blend = None
|
||||
|
||||
# When no explicit voice/blend/speed provided, load all voice settings from the user's profile
|
||||
if "voice" not in data and "voice_blend" not in data and "speed" not in data:
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.auth import get_current_user_id
|
||||
import json as _json
|
||||
try:
|
||||
uid = get_current_user_id()
|
||||
saved_voice = await get_setting(uid, "voice_tts_voice", "")
|
||||
if saved_voice:
|
||||
voice = saved_voice
|
||||
saved_speed = await get_setting(uid, "voice_tts_speed", "")
|
||||
if saved_speed:
|
||||
try:
|
||||
speed = float(saved_speed)
|
||||
except ValueError:
|
||||
pass
|
||||
saved_blend = await get_setting(uid, "voice_tts_blend", "")
|
||||
if saved_blend:
|
||||
parsed = _json.loads(saved_blend)
|
||||
if isinstance(parsed, list) and len(parsed) >= 2:
|
||||
voice_blend = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
blend_desc = f"blend({len(voice_blend)} voices)" if voice_blend else voice
|
||||
logger.info("TTS synthesis start: %d chars, voice=%s, speed=%.2f", char_count, blend_desc, speed)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
wav_bytes = await synthesise(text, voice=voice, speed=speed)
|
||||
wav_bytes = await synthesise(text, voice=voice, speed=speed, voice_blend=voice_blend)
|
||||
except Exception:
|
||||
logger.exception("TTS synthesis failed")
|
||||
logger.exception(
|
||||
"TTS synthesis failed: %d chars, voice=%s. Preview: %r",
|
||||
char_count, blend_desc, text[:120],
|
||||
)
|
||||
return jsonify({"error": "Synthesis failed"}), 500
|
||||
|
||||
duration_ms = round((time.monotonic() - t0) * 1000)
|
||||
if not wav_bytes:
|
||||
logger.warning(
|
||||
"TTS synthesis returned empty audio: %d chars, voice=%s, %dms. Preview: %r",
|
||||
char_count, blend_desc, duration_ms, text[:120],
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"TTS synthesis complete: %d chars → %d bytes in %dms (voice=%s)",
|
||||
char_count, len(wav_bytes), duration_ms, blend_desc,
|
||||
)
|
||||
|
||||
from quart import Response
|
||||
return Response(wav_bytes, mimetype="audio/wav")
|
||||
|
||||
@@ -7,7 +7,7 @@ Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import httpx
|
||||
@@ -22,14 +22,6 @@ logger = logging.getLogger(__name__)
|
||||
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
|
||||
|
||||
|
||||
def slot_greeting(slot: str) -> str:
|
||||
return {
|
||||
"compilation": "Good morning",
|
||||
"morning": "Good morning — you're at the office",
|
||||
"midday": "Midday check-in",
|
||||
"afternoon": "End of day wrap-up",
|
||||
}.get(slot, "Update")
|
||||
|
||||
|
||||
def format_task(task: dict) -> str:
|
||||
parts = [task.get("title", "Untitled")]
|
||||
@@ -180,14 +172,18 @@ async def _gather_internal(user_id: int) -> dict:
|
||||
user_id=user_id, date_from=day_start, date_to=day_end
|
||||
)
|
||||
for e in internal_events:
|
||||
if e.all_day:
|
||||
if e.get("all_day"):
|
||||
time_str = "all day"
|
||||
elif e.start_dt:
|
||||
local_dt = e.start_dt.astimezone(user_tz) if e.start_dt.tzinfo else e.start_dt.replace(tzinfo=timezone.utc).astimezone(user_tz)
|
||||
elif e.get("start_dt"):
|
||||
from datetime import datetime as _dt
|
||||
start = e["start_dt"]
|
||||
if isinstance(start, str):
|
||||
start = _dt.fromisoformat(start)
|
||||
local_dt = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
|
||||
time_str = local_dt.strftime("%-I:%M %p")
|
||||
else:
|
||||
time_str = "unknown time"
|
||||
calendar_events.append(f"{e.title} at {time_str}")
|
||||
calendar_events.append(f"{e.get('title', 'Event')} at {time_str}")
|
||||
except Exception:
|
||||
logger.warning("Failed to gather internal calendar events for briefing", exc_info=True)
|
||||
# Also pull CalDAV events (deduped)
|
||||
@@ -241,7 +237,7 @@ async def _gather_external(user_id: int) -> dict:
|
||||
|
||||
# ── LLM synthesis ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> str:
|
||||
async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str, num_ctx: int = 4096) -> str:
|
||||
"""Single non-streaming LLM call. Returns the assistant's response text."""
|
||||
payload = {
|
||||
"model": model,
|
||||
@@ -250,7 +246,7 @@ async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> s
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
"options": {"num_ctx": 4096, "temperature": 0.4},
|
||||
"options": {"num_ctx": num_ctx, "temperature": 0.4},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
@@ -263,57 +259,73 @@ async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> s
|
||||
return ""
|
||||
|
||||
|
||||
def _internal_system_prompt(profile_body: str) -> str:
|
||||
def _unified_system_prompt(profile_body: str) -> str:
|
||||
return (
|
||||
"You are a personal briefing assistant. Your job is to give the user a clear, "
|
||||
"concise summary of their internal workload: tasks, calendar, and projects. "
|
||||
"Be direct and prioritised — lead with what's urgent. Use plain text with light "
|
||||
"markdown. Do not include weather or news.\n\n"
|
||||
"You are a personal assistant delivering a daily briefing. "
|
||||
"Speak naturally and conversationally — as if talking to the user, not writing a report. "
|
||||
"Use no markdown: no headers, no bullet points, no bold, no lists. Write in flowing prose. "
|
||||
"Weave together what matters today: mention the weather in a sentence, note any calendar "
|
||||
"events or tasks due today, and briefly reference one or two noteworthy news stories. "
|
||||
"Only mention projects if a task from one is specifically due today. "
|
||||
"Be warm, concise, and human — aim for 3 to 5 sentences. "
|
||||
"Future context like emails and messages will be added over time — keep the tone open and helpful.\n\n"
|
||||
+ (f"User profile:\n{profile_body}\n" if profile_body else "")
|
||||
)
|
||||
|
||||
|
||||
def _external_system_prompt() -> str:
|
||||
return (
|
||||
"You are a briefing assistant for external information. Your job is to present "
|
||||
"selected news items and summarise any remaining RSS content. "
|
||||
"IMPORTANT: Weather is handled separately — do NOT include any weather section.\n\n"
|
||||
"Format each news item EXACTLY as:\n"
|
||||
"**[Headline text](source_url)**\n"
|
||||
"*Outlet Name · Day Month*\n"
|
||||
"One or two sentence summary.\n\n"
|
||||
"Present news items in the EXACT ORDER they are provided. Do not reorder them. "
|
||||
"After the news cards, add a brief paragraph for any remaining context."
|
||||
)
|
||||
def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str:
|
||||
lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""]
|
||||
|
||||
# Weather (brief — card handles detail)
|
||||
weather = external_data.get("weather") or []
|
||||
if weather:
|
||||
loc = weather[0]
|
||||
days = loc.get("days") or []
|
||||
if days:
|
||||
d = days[0]
|
||||
t_min = _format_temp(d["temp_min"], temp_unit)
|
||||
t_max = _format_temp(d["temp_max"], temp_unit)
|
||||
unit_sym = f"°{temp_unit}"
|
||||
lines.append(
|
||||
f"WEATHER: {loc['location_label']} — {d['description']}, "
|
||||
f"{t_min}–{t_max}{unit_sym}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
def _internal_user_prompt(data: dict, slot: str) -> str:
|
||||
lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""]
|
||||
if data.get("unchanged_task_count", 0) > 0:
|
||||
lines.append(
|
||||
f"({data['unchanged_task_count']} tasks are unchanged since the last briefing "
|
||||
"— acknowledge briefly, do not list them.)"
|
||||
)
|
||||
# Today's calendar events
|
||||
if internal_data.get("calendar_events"):
|
||||
lines.append("TODAY'S EVENTS:")
|
||||
lines.extend(f" - {e}" for e in internal_data["calendar_events"])
|
||||
lines.append("")
|
||||
changed = data.get("changed_tasks") or data.get("overdue_tasks", [])
|
||||
if changed:
|
||||
lines.append(f"CHANGED/NEW TASKS ({len(changed)}):")
|
||||
lines.extend(f" - {t}" for t in changed)
|
||||
|
||||
# Tasks due today
|
||||
if internal_data.get("due_today"):
|
||||
lines.append("DUE TODAY:")
|
||||
lines.extend(f" - {t}" for t in internal_data["due_today"])
|
||||
lines.append("")
|
||||
if data.get("due_today"):
|
||||
lines.append(f"DUE TODAY ({len(data['due_today'])}):")
|
||||
lines.extend(f" - {t}" for t in data["due_today"])
|
||||
|
||||
# Overdue tasks (brief mention only)
|
||||
if internal_data.get("overdue_tasks"):
|
||||
overdue = internal_data["overdue_tasks"]
|
||||
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}):")
|
||||
lines.extend(f" - {t}" for t in overdue[:3])
|
||||
if len(overdue) > 3:
|
||||
lines.append(f" (and {len(overdue) - 3} more)")
|
||||
lines.append("")
|
||||
if data.get("high_priority"):
|
||||
lines.append("HIGH PRIORITY (in progress):")
|
||||
lines.extend(f" - {t}" for t in data["high_priority"])
|
||||
|
||||
# News highlights (top 3 with excerpts — right panel shows full list)
|
||||
rss = external_data.get("rss_items") or []
|
||||
if rss:
|
||||
lines.append("NEWS HIGHLIGHTS (weave 1-2 into your briefing naturally; the full list is shown separately):")
|
||||
for item in rss[:3]:
|
||||
source = item.get("feed_title") or item.get("source") or "News"
|
||||
title = item.get("title", "")
|
||||
excerpt = (item.get("content") or item.get("snippet") or "")[:500].strip()
|
||||
lines.append(f" [{source}] {title}")
|
||||
if excerpt:
|
||||
lines.append(f" {excerpt}")
|
||||
lines.append("")
|
||||
if data["calendar_events"]:
|
||||
lines.append("CALENDAR TODAY:")
|
||||
lines.extend(f" - {e}" for e in data["calendar_events"])
|
||||
lines.append("")
|
||||
if data["active_projects"]:
|
||||
lines.append(f"ACTIVE PROJECTS: {', '.join(data['active_projects'])}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -324,31 +336,6 @@ def _format_temp(value: float, unit: str) -> str:
|
||||
return f"{value:.0f}"
|
||||
|
||||
|
||||
def _external_user_prompt(data: dict, slot: str, temp_unit: str = "C") -> str:
|
||||
unit_sym = f"°{temp_unit}"
|
||||
lines = [f"Briefing slot: {slot}", ""]
|
||||
if data["weather"]:
|
||||
lines.append("WEATHER:")
|
||||
for loc in data["weather"]:
|
||||
lines.append(f" {loc['location_label']}:")
|
||||
for day in loc["days"][:3]:
|
||||
t_min = _format_temp(day["temp_min"], temp_unit)
|
||||
t_max = _format_temp(day["temp_max"], temp_unit)
|
||||
lines.append(
|
||||
f" {day['date']}: {day['description']}, "
|
||||
f"{t_min}–{t_max}{unit_sym}, {day['precip_mm']}mm rain"
|
||||
)
|
||||
if loc["changes_since_last_fetch"]:
|
||||
lines.append(" FORECAST CHANGES:")
|
||||
lines.extend(f" - {c}" for c in loc["changes_since_last_fetch"])
|
||||
lines.append("")
|
||||
if data["rss_items"]:
|
||||
lines.append(f"RSS DIGEST ({len(data['rss_items'])} items):")
|
||||
for item in data["rss_items"][:15]:
|
||||
lines.append(f" [{item.get('feed_title', 'Feed')}] {item['title']}")
|
||||
if item.get("content"):
|
||||
lines.append(f" {item['content'][:200]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Main entry point ───────────────────────────────────────────────────────────
|
||||
@@ -378,7 +365,7 @@ async def run_compilation(
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
from fabledassistant.services.briefing_profile import get_profile_body
|
||||
from fabledassistant.services.user_profile import build_profile_context
|
||||
from fabledassistant.services.briefing_preferences import (
|
||||
load_topic_preferences,
|
||||
load_topic_reaction_scores,
|
||||
@@ -386,8 +373,8 @@ async def run_compilation(
|
||||
)
|
||||
from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows
|
||||
|
||||
profile_body, temp_unit = await asyncio.gather(
|
||||
get_profile_body(user_id),
|
||||
profile_context, temp_unit = await asyncio.gather(
|
||||
build_profile_context(user_id),
|
||||
_get_temp_unit(user_id),
|
||||
)
|
||||
|
||||
@@ -434,7 +421,6 @@ async def run_compilation(
|
||||
|
||||
# ── LLM Synthesis ──────────────────────────────────────────────────────────
|
||||
# Build filtered internal data with only changed tasks
|
||||
today = internal_data["date"]
|
||||
internal_data_filtered = dict(internal_data)
|
||||
internal_data_filtered["unchanged_task_count"] = unchanged_count
|
||||
internal_data_filtered["changed_tasks"] = [format_task(t) for t in changed_tasks]
|
||||
@@ -445,17 +431,11 @@ async def run_compilation(
|
||||
"weather": [],
|
||||
}
|
||||
|
||||
internal_text, external_text = await asyncio.gather(
|
||||
_llm_synthesise(
|
||||
_internal_system_prompt(profile_body),
|
||||
_internal_user_prompt(internal_data_filtered, slot),
|
||||
model,
|
||||
),
|
||||
_llm_synthesise(
|
||||
_external_system_prompt(),
|
||||
_external_user_prompt(external_data_filtered, slot, temp_unit),
|
||||
model,
|
||||
),
|
||||
briefing_text = await _llm_synthesise(
|
||||
_unified_system_prompt(profile_context),
|
||||
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
|
||||
model,
|
||||
num_ctx=8192,
|
||||
)
|
||||
|
||||
# ── Post-processing ─────────────────────────────────────────────────────────
|
||||
@@ -463,18 +443,11 @@ async def run_compilation(
|
||||
|
||||
metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "weather": weather_card}
|
||||
|
||||
if not internal_text and not external_text:
|
||||
if not briefing_text:
|
||||
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
|
||||
return "", metadata
|
||||
|
||||
greeting = slot_greeting(slot)
|
||||
parts = [f"**{greeting} — {today}**", ""]
|
||||
if internal_text:
|
||||
parts += ["## Your Day", "", internal_text, ""]
|
||||
if external_text:
|
||||
parts += ["## The World", "", external_text]
|
||||
|
||||
return "\n".join(parts).strip(), metadata
|
||||
return briefing_text, metadata
|
||||
|
||||
|
||||
async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str:
|
||||
@@ -492,13 +465,12 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None)
|
||||
)
|
||||
|
||||
system = (
|
||||
f"You are a briefing assistant providing a {slot} update. Be brief — "
|
||||
"the user has already seen the morning briefing. Focus on what's changed or new."
|
||||
f"You are a personal assistant giving a brief {slot} check-in. "
|
||||
"The user already had their morning briefing — focus only on what's changed or newly relevant. "
|
||||
"Speak naturally in 2-3 sentences, no markdown formatting, no headers or bullet points."
|
||||
)
|
||||
user_prompt = (
|
||||
f"Slot: {slot}\n\n"
|
||||
+ _internal_user_prompt(internal_data, slot)
|
||||
+ "\n\n"
|
||||
+ _external_user_prompt(external_data, slot, temp_unit)
|
||||
return await _llm_synthesise(
|
||||
system,
|
||||
_unified_user_prompt(internal_data, external_data, slot, temp_unit),
|
||||
model,
|
||||
)
|
||||
return await _llm_synthesise(system, user_prompt, model)
|
||||
|
||||
@@ -51,8 +51,8 @@ def _resolve_timezone(tz_str: str) -> str:
|
||||
return "UTC"
|
||||
|
||||
|
||||
async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
|
||||
"""Return [(user_id, iana_timezone)] for all users with briefing enabled."""
|
||||
async def _get_briefing_enabled_users() -> list[tuple[int, str, dict]]:
|
||||
"""Return [(user_id, iana_timezone, config)] for all users with briefing enabled."""
|
||||
import json
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
@@ -71,7 +71,7 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
|
||||
if config.get("enabled"):
|
||||
tz_str = settings.get("user_timezone") or config.get("timezone", "UTC")
|
||||
tz = _resolve_timezone(tz_str)
|
||||
enabled.append((user_id, tz))
|
||||
enabled.append((user_id, tz, config))
|
||||
except Exception:
|
||||
pass
|
||||
return enabled
|
||||
@@ -81,16 +81,24 @@ def _job_id(user_id: int, slot: str) -> str:
|
||||
return f"briefing_{slot}_user_{user_id}"
|
||||
|
||||
|
||||
def _add_user_jobs(user_id: int, tz: str) -> None:
|
||||
"""Add (or replace) all 4 slot jobs for a user in their timezone."""
|
||||
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
||||
"""Add (or replace) slot jobs for a user, skipping disabled slots."""
|
||||
if _scheduler is None or _loop is None:
|
||||
return
|
||||
enabled_slots = (config or {}).get("slots", {})
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
jid = _job_id(user_id, slot_name)
|
||||
# compilation always runs; other slots default to True if not in config
|
||||
slot_on = enabled_slots.get(slot_name, True)
|
||||
if not slot_on:
|
||||
if _scheduler.get_job(jid):
|
||||
_scheduler.remove_job(jid)
|
||||
continue
|
||||
_scheduler.add_job(
|
||||
_run_user_slot_sync,
|
||||
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
||||
args=[user_id, slot_name],
|
||||
id=_job_id(user_id, slot_name),
|
||||
id=jid,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
@@ -119,7 +127,7 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N
|
||||
if config.get("enabled"):
|
||||
tz_str = tz_override or config.get("timezone", "UTC")
|
||||
tz = _resolve_timezone(tz_str)
|
||||
_add_user_jobs(user_id, tz)
|
||||
_add_user_jobs(user_id, tz, config)
|
||||
else:
|
||||
_remove_user_jobs(user_id)
|
||||
|
||||
@@ -135,6 +143,24 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.config import Config
|
||||
|
||||
# Morning slot: skip if today is not a configured work day
|
||||
if slot == "morning":
|
||||
from fabledassistant.services.user_profile import get_profile
|
||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
|
||||
profile = await get_profile(user_id)
|
||||
work_days = (profile.work_schedule or {}).get("days", ["Mon", "Tue", "Wed", "Thu", "Fri"])
|
||||
if today_abbr not in work_days:
|
||||
logger.info(
|
||||
"Skipping morning slot for user %d — %s not a configured work day",
|
||||
user_id, today_abbr,
|
||||
)
|
||||
return
|
||||
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
if slot == "compilation":
|
||||
@@ -208,7 +234,7 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
|
||||
Read yesterday's briefing conversation, extract preference observations,
|
||||
and append them to the briefing profile note.
|
||||
"""
|
||||
from fabledassistant.services.briefing_profile import append_observations
|
||||
from fabledassistant.services.user_profile import append_observations
|
||||
from fabledassistant.services.briefing_pipeline import _llm_synthesise
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
|
||||
@@ -255,7 +281,7 @@ async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
|
||||
(one catch-up per slot per user, evaluated in the user's local timezone).
|
||||
"""
|
||||
users = await _get_briefing_enabled_users()
|
||||
for user_id, tz in users:
|
||||
for user_id, tz, _config in users:
|
||||
user_tz = ZoneInfo(tz)
|
||||
now_local = datetime.now(user_tz)
|
||||
today_local = now_local.date()
|
||||
@@ -323,8 +349,8 @@ async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
logger.exception("Failed to load briefing users at startup")
|
||||
users = []
|
||||
|
||||
for user_id, tz in users:
|
||||
_add_user_jobs(user_id, tz)
|
||||
for user_id, tz, config in users:
|
||||
_add_user_jobs(user_id, tz, config)
|
||||
|
||||
from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring
|
||||
|
||||
@@ -343,6 +369,21 @@ async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
def _run_kokoro_update_check() -> None:
|
||||
from fabledassistant.services.tts import check_for_kokoro_updates
|
||||
future = asyncio.run_coroutine_threadsafe(check_for_kokoro_updates(), _loop)
|
||||
try:
|
||||
future.result(timeout=300)
|
||||
except Exception as exc:
|
||||
logger.error("Kokoro update check failed: %s", exc)
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_kokoro_update_check,
|
||||
CronTrigger(hour=3, minute=0, timezone="UTC"),
|
||||
id="kokoro_update_check_daily",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info(
|
||||
"Briefing scheduler started with %d user(s) across %d job(s)",
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""CalDAV pull sync — imports remote events into the internal event store.
|
||||
|
||||
Runs as a scheduled job (hourly) and is also callable via the API.
|
||||
Only syncs events in a rolling 30-day-past / 180-day-future window.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYNC_PAST_DAYS = 30
|
||||
_SYNC_FUTURE_DAYS = 180
|
||||
|
||||
|
||||
def _parse_dt(val: Any) -> datetime | None:
|
||||
"""Convert a date or datetime from an iCal component to a UTC-aware datetime."""
|
||||
if val is None:
|
||||
return None
|
||||
import datetime as _dt_mod
|
||||
if isinstance(val, _dt_mod.datetime):
|
||||
if val.tzinfo is None:
|
||||
return val.replace(tzinfo=timezone.utc)
|
||||
return val.astimezone(timezone.utc)
|
||||
if isinstance(val, _dt_mod.date):
|
||||
# All-day date: treat as midnight UTC
|
||||
return datetime(val.year, val.month, val.day, tzinfo=timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def _sync_one_user(config: dict[str, str], user_id: int) -> list[dict]:
|
||||
"""Synchronous CalDAV fetch — runs in a thread executor."""
|
||||
import caldav # noqa: PLC0415
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
range_start = now - timedelta(days=_SYNC_PAST_DAYS)
|
||||
range_end = now + timedelta(days=_SYNC_FUTURE_DAYS)
|
||||
|
||||
client = caldav.DAVClient(
|
||||
url=config["caldav_url"],
|
||||
username=config.get("caldav_username") or None,
|
||||
password=config.get("caldav_password") or None,
|
||||
)
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
if not calendars:
|
||||
return []
|
||||
|
||||
cal_name = config.get("caldav_calendar_name", "")
|
||||
if cal_name:
|
||||
calendars = [c for c in calendars if c.name == cal_name] or calendars
|
||||
|
||||
events: list[dict] = []
|
||||
for calendar in calendars:
|
||||
try:
|
||||
results = calendar.date_search(start=range_start, end=range_end, expand=False)
|
||||
except Exception:
|
||||
logger.warning("CalDAV date_search failed for calendar %s", getattr(calendar, "name", "?"), exc_info=True)
|
||||
continue
|
||||
for vevent_obj in results:
|
||||
try:
|
||||
ical = vevent_obj.icalendar_instance
|
||||
for component in ical.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
dtstart = component.get("DTSTART")
|
||||
dtend = component.get("DTEND")
|
||||
uid = str(component.get("UID", ""))
|
||||
if not uid:
|
||||
continue
|
||||
start_dt = _parse_dt(dtstart.dt if dtstart else None)
|
||||
end_dt = _parse_dt(dtend.dt if dtend else None)
|
||||
if start_dt is None:
|
||||
continue
|
||||
|
||||
import datetime as _dt_mod
|
||||
all_day = dtstart and isinstance(dtstart.dt, _dt_mod.date) and not isinstance(dtstart.dt, _dt_mod.datetime)
|
||||
|
||||
rrule = component.get("RRULE")
|
||||
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
|
||||
|
||||
events.append({
|
||||
"caldav_uid": uid,
|
||||
"title": str(component.get("SUMMARY", "")),
|
||||
"start_dt": start_dt,
|
||||
"end_dt": end_dt,
|
||||
"all_day": bool(all_day),
|
||||
"description": str(component.get("DESCRIPTION", "")),
|
||||
"location": str(component.get("LOCATION", "")),
|
||||
"recurrence": recurrence,
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("Failed to parse CalDAV event", exc_info=True)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def sync_user_events(user_id: int) -> dict:
|
||||
"""Pull CalDAV events for one user and upsert into the DB.
|
||||
|
||||
Returns a summary dict: {created, updated, unchanged}.
|
||||
"""
|
||||
from fabledassistant.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415
|
||||
|
||||
if not await is_caldav_configured(user_id):
|
||||
return {"skipped": True, "reason": "CalDAV not configured"}
|
||||
|
||||
config = await get_caldav_config(user_id)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
remote_events: list[dict] = await loop.run_in_executor(
|
||||
None, _sync_one_user, config, user_id
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True)
|
||||
return {"error": "CalDAV fetch failed"}
|
||||
|
||||
created = updated = unchanged = 0
|
||||
|
||||
async with async_session() as session:
|
||||
for ev in remote_events:
|
||||
caldav_uid = ev["caldav_uid"]
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.caldav_uid == caldav_uid,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is None:
|
||||
# Create new event
|
||||
new_ev = Event(
|
||||
user_id=user_id,
|
||||
uid=str(uuid.uuid4()),
|
||||
caldav_uid=caldav_uid,
|
||||
title=ev["title"],
|
||||
start_dt=ev["start_dt"],
|
||||
end_dt=ev["end_dt"],
|
||||
all_day=ev["all_day"],
|
||||
description=ev["description"],
|
||||
location=ev["location"],
|
||||
recurrence=ev["recurrence"],
|
||||
)
|
||||
session.add(new_ev)
|
||||
created += 1
|
||||
else:
|
||||
# Update if anything changed
|
||||
changed = False
|
||||
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"):
|
||||
if getattr(existing, field) != ev[field]:
|
||||
setattr(existing, field, ev[field])
|
||||
changed = True
|
||||
if changed:
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"CalDAV sync user %d: %d created, %d updated, %d unchanged",
|
||||
user_id, created, updated, unchanged,
|
||||
)
|
||||
return {"created": created, "updated": updated, "unchanged": unchanged}
|
||||
|
||||
|
||||
async def sync_all_users() -> None:
|
||||
"""Pull CalDAV events for all users with CalDAV configured."""
|
||||
from sqlalchemy import select as sa_select # noqa: PLC0415
|
||||
|
||||
from fabledassistant.models.user import User # noqa: PLC0415
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(sa_select(User.id))
|
||||
user_ids = [row[0] for row in result.all()]
|
||||
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
await sync_user_events(user_id)
|
||||
except Exception:
|
||||
logger.warning("CalDAV sync failed for user %d", user_id, exc_info=True)
|
||||
@@ -81,6 +81,7 @@ async def list_conversations(
|
||||
"model": conv.model,
|
||||
"conversation_type": conv.conversation_type,
|
||||
"briefing_date": conv.briefing_date.isoformat() if conv.briefing_date else None,
|
||||
"rag_project_id": conv.rag_project_id,
|
||||
"message_count": row[1],
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
"updated_at": conv.updated_at.isoformat(),
|
||||
@@ -131,8 +132,9 @@ async def cleanup_old_conversations(user_id: int, days: int) -> int:
|
||||
.where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.updated_at < cutoff,
|
||||
Conversation.conversation_type != "mcp", # preserve MCP audit trail
|
||||
Conversation.conversation_type != "voice", # voice convs managed separately
|
||||
Conversation.conversation_type != "mcp", # preserve MCP audit trail
|
||||
Conversation.conversation_type != "voice", # voice convs managed separately
|
||||
Conversation.conversation_type != "briefing", # briefing history managed by briefing system
|
||||
)
|
||||
.returning(Conversation.id)
|
||||
)
|
||||
@@ -184,6 +186,7 @@ async def add_message(
|
||||
content: str,
|
||||
context_note_id: int | None = None,
|
||||
status: str | None = None,
|
||||
tool_calls: list | None = None,
|
||||
) -> Message:
|
||||
async with async_session() as session:
|
||||
kwargs: dict = dict(
|
||||
@@ -194,6 +197,8 @@ async def add_message(
|
||||
)
|
||||
if status is not None:
|
||||
kwargs["status"] = status
|
||||
if tool_calls is not None:
|
||||
kwargs["tool_calls"] = tool_calls
|
||||
msg = Message(**kwargs)
|
||||
session.add(msg)
|
||||
# Touch conversation updated_at
|
||||
@@ -223,9 +228,6 @@ async def save_response_as_note(user_id: int, message_id: int) -> dict:
|
||||
# Generate title via LLM using the assistant message content
|
||||
title = ""
|
||||
if conv:
|
||||
model = conv.model or await get_setting(
|
||||
user_id, "default_model", Config.OLLAMA_MODEL
|
||||
)
|
||||
try:
|
||||
prompt_messages = [
|
||||
{
|
||||
@@ -238,7 +240,8 @@ async def save_response_as_note(user_id: int, message_id: int) -> dict:
|
||||
},
|
||||
{"role": "user", "content": msg.content[:2000]},
|
||||
]
|
||||
title = await generate_completion(prompt_messages, model)
|
||||
bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
|
||||
title = await generate_completion(prompt_messages, bg_model)
|
||||
title = title.strip().strip('"\'').strip()[:100]
|
||||
except Exception:
|
||||
logger.warning("Failed to generate note title, using fallback", exc_info=True)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Semantic note search via Ollama embedding model (nomic-embed-text).
|
||||
|
||||
Embeddings are stored in the note_embeddings table (one row per note).
|
||||
RSS item embeddings are stored in rss_item_embeddings (one row per item).
|
||||
All search operations degrade gracefully — if the embedding model is
|
||||
unavailable the callers fall back to keyword search.
|
||||
"""
|
||||
@@ -8,6 +9,7 @@ unavailable the callers fall back to keyword search.
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import delete, select
|
||||
@@ -16,6 +18,8 @@ from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.embedding import NoteEmbedding
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
from fabledassistant.models.rss_item_embedding import RssItemEmbedding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,6 +28,10 @@ logger = logging.getLogger(__name__)
|
||||
# 0.45 keeps only genuinely relevant notes; lower values like 0.30 let in
|
||||
# loosely-related results that pad the sidebar without adding real value.
|
||||
_SIMILARITY_THRESHOLD = 0.45
|
||||
_RSS_SIMILARITY_THRESHOLD = 0.55
|
||||
_RSS_SEARCH_LIMIT = 3
|
||||
_RSS_SEARCH_DAYS = 30
|
||||
_RSS_SNIPPET_CHARS = 500
|
||||
|
||||
|
||||
async def get_embedding(text: str, model: str | None = None) -> list[float]:
|
||||
@@ -55,6 +63,8 @@ def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
|
||||
async def upsert_note_embedding(note_id: int, user_id: int, text: str) -> None:
|
||||
"""Generate and persist an embedding for a note. Safe to fire-and-forget."""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
try:
|
||||
embedding = await get_embedding(text)
|
||||
except Exception:
|
||||
@@ -89,6 +99,8 @@ async def semantic_search_notes(
|
||||
*threshold* are returned, sorted highest-first.
|
||||
Returns an empty list if the embedding model is unavailable or on any error.
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
try:
|
||||
query_vec = await get_embedding(query)
|
||||
except Exception:
|
||||
@@ -172,3 +184,176 @@ async def backfill_note_embeddings() -> None:
|
||||
await asyncio.sleep(0.05) # gentle pacing
|
||||
|
||||
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
|
||||
|
||||
|
||||
# ── RSS item embeddings ───────────────────────────────────────────────────────
|
||||
|
||||
async def upsert_rss_item_embedding(item_id: int, user_id: int, title: str, content: str) -> None:
|
||||
"""Generate and persist an embedding for an RSS item. Safe to fire-and-forget."""
|
||||
text = f"{title}\n{content}".strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
embedding = await get_embedding(text)
|
||||
except Exception:
|
||||
logger.debug("Skipping embedding for RSS item %d — model unavailable", item_id)
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
delete(RssItemEmbedding).where(RssItemEmbedding.rss_item_id == item_id)
|
||||
)
|
||||
session.add(RssItemEmbedding(rss_item_id=item_id, user_id=user_id, embedding=embedding))
|
||||
await session.commit()
|
||||
logger.debug("Upserted embedding for RSS item %d", item_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to persist embedding for RSS item %d", item_id, exc_info=True)
|
||||
|
||||
|
||||
async def semantic_search_rss_items(
|
||||
user_id: int,
|
||||
query_vector: list[float],
|
||||
limit: int = _RSS_SEARCH_LIMIT,
|
||||
days: int = _RSS_SEARCH_DAYS,
|
||||
) -> list[tuple[float, RssItem]]:
|
||||
"""Return up to *limit* (score, RssItem) pairs most relevant to *query_vector*.
|
||||
|
||||
Only considers items fetched within the last *days* days.
|
||||
Returns an empty list on any error.
|
||||
"""
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
try:
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(RssItemEmbedding, RssItem)
|
||||
.join(RssItem, RssItemEmbedding.rss_item_id == RssItem.id)
|
||||
.where(
|
||||
RssItemEmbedding.user_id == user_id,
|
||||
RssItem.fetched_at >= since,
|
||||
)
|
||||
)
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
except Exception:
|
||||
logger.warning("Failed to query RSS item embeddings", exc_info=True)
|
||||
return []
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
scored: list[tuple[float, RssItem]] = []
|
||||
for rie, item in rows:
|
||||
try:
|
||||
sim = _cosine_similarity(query_vector, rie.embedding)
|
||||
except Exception:
|
||||
continue
|
||||
if sim >= _RSS_SIMILARITY_THRESHOLD:
|
||||
scored.append((sim, item))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
|
||||
async def backfill_rss_item_embeddings() -> None:
|
||||
"""Generate embeddings for all RSS items that don't have one yet.
|
||||
|
||||
Runs as a background task at startup. Adds a small sleep between items
|
||||
to avoid overwhelming Ollama.
|
||||
"""
|
||||
try:
|
||||
async with async_session() as session:
|
||||
existing = {
|
||||
row[0]
|
||||
for row in (
|
||||
await session.execute(select(RssItemEmbedding.rss_item_id))
|
||||
).fetchall()
|
||||
}
|
||||
result = await session.execute(
|
||||
select(RssItem.id, RssItem.feed_id, RssItem.title, RssItem.content)
|
||||
)
|
||||
items_to_embed = [row for row in result.fetchall() if row[0] not in existing]
|
||||
except Exception:
|
||||
logger.warning("RSS embedding backfill: failed to query items", exc_info=True)
|
||||
return
|
||||
|
||||
if not items_to_embed:
|
||||
logger.info("RSS embedding backfill: all items already have embeddings")
|
||||
return
|
||||
|
||||
# Resolve user_id per feed_id
|
||||
try:
|
||||
from fabledassistant.models.rss_feed import RssFeed
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(RssFeed.id, RssFeed.user_id))
|
||||
feed_user_map = {fid: uid for fid, uid in result.fetchall()}
|
||||
except Exception:
|
||||
logger.warning("RSS embedding backfill: failed to load feed user map", exc_info=True)
|
||||
return
|
||||
|
||||
logger.info("RSS embedding backfill: generating embeddings for %d items", len(items_to_embed))
|
||||
success = 0
|
||||
for item_id, feed_id, title, content in items_to_embed:
|
||||
user_id = feed_user_map.get(feed_id)
|
||||
if user_id is None:
|
||||
continue
|
||||
await upsert_rss_item_embedding(item_id, user_id, title or "", content or "")
|
||||
success += 1
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
logger.info("RSS embedding backfill complete: %d/%d items embedded", success, len(items_to_embed))
|
||||
|
||||
|
||||
async def backfill_rss_article_content() -> None:
|
||||
"""Fetch full article text for RSS items that only have short feed-provided content.
|
||||
|
||||
An item is considered unenriched if its content is shorter than 1000 chars —
|
||||
typical of feed summaries/teasers rather than full articles.
|
||||
Runs at startup after the embedding backfill.
|
||||
"""
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
from fabledassistant.models.rss_feed import RssFeed
|
||||
|
||||
SHORT_THRESHOLD = 1000
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
feed_result = await session.execute(select(RssFeed.id, RssFeed.user_id))
|
||||
feed_user_map = {fid: uid for fid, uid in feed_result.fetchall()}
|
||||
|
||||
item_result = await session.execute(
|
||||
select(RssItem.id, RssItem.feed_id, RssItem.url, RssItem.title, RssItem.content)
|
||||
.where(RssItem.url != "")
|
||||
)
|
||||
candidates = [
|
||||
row for row in item_result.fetchall()
|
||||
if len(row[4] or "") < SHORT_THRESHOLD
|
||||
]
|
||||
except Exception:
|
||||
logger.warning("Article content backfill: failed to query items", exc_info=True)
|
||||
return
|
||||
|
||||
if not candidates:
|
||||
logger.info("Article content backfill: no unenriched items found")
|
||||
return
|
||||
|
||||
logger.info("Article content backfill: enriching %d items", len(candidates))
|
||||
enriched = 0
|
||||
for item_id, feed_id, url, title, _ in candidates:
|
||||
user_id = feed_user_map.get(feed_id)
|
||||
if user_id is None:
|
||||
continue
|
||||
full_text = await _fetch_full_article(url)
|
||||
if full_text and len(full_text) > SHORT_THRESHOLD:
|
||||
try:
|
||||
async with async_session() as session:
|
||||
item = await session.get(RssItem, item_id)
|
||||
if item:
|
||||
item.content = full_text
|
||||
await session.commit()
|
||||
await upsert_rss_item_embedding(item_id, user_id, title or "", full_text)
|
||||
enriched += 1
|
||||
except Exception:
|
||||
logger.debug("Failed to store enriched content for item %d", item_id, exc_info=True)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.info("Article content backfill complete: %d/%d items enriched", enriched, len(candidates))
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Scheduler jobs for background maintenance tasks.
|
||||
|
||||
- Reminder notifications: checks every 5 minutes for due event reminders.
|
||||
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
|
||||
- Chat retention cleanup: runs daily, deleting old conversations per user setting.
|
||||
|
||||
Uses the same BackgroundScheduler pattern as briefing_scheduler.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reminder job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fire_reminders() -> None:
|
||||
"""Find events with reminders due in the next 5 minutes and fire push notifications."""
|
||||
now = datetime.now(timezone.utc)
|
||||
window_end = now + timedelta(minutes=5)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.reminder_minutes.isnot(None),
|
||||
Event.reminder_sent_at.is_(None),
|
||||
Event.start_dt > now, # event hasn't started yet
|
||||
# reminder fires when now >= start_dt - reminder_minutes
|
||||
# i.e. start_dt <= now + reminder_minutes (approximated by window_end check)
|
||||
)
|
||||
)
|
||||
candidates = list(result.scalars().all())
|
||||
|
||||
to_notify: list[Event] = []
|
||||
for event in candidates:
|
||||
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
|
||||
if reminder_dt <= window_end:
|
||||
to_notify.append(event)
|
||||
|
||||
if not to_notify:
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
for event in to_notify:
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification # noqa: PLC0415
|
||||
start_local = event.start_dt.strftime("%H:%M")
|
||||
await send_push_notification(
|
||||
user_id=event.user_id,
|
||||
title=f"Reminder: {event.title}",
|
||||
body=f"Starting at {start_local} UTC",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to send reminder push for event %d", event.id, exc_info=True)
|
||||
|
||||
# Mark as sent regardless of push success to avoid re-firing
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event.id)
|
||||
)
|
||||
ev = result.scalar_one_or_none()
|
||||
if ev:
|
||||
ev.reminder_sent_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _run_reminders(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_fire_reminders(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CalDAV pull sync job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_caldav_sync() -> None:
|
||||
from fabledassistant.services.caldav_sync import sync_all_users # noqa: PLC0415
|
||||
try:
|
||||
await sync_all_users()
|
||||
except Exception:
|
||||
logger.warning("CalDAV pull sync job failed", exc_info=True)
|
||||
|
||||
|
||||
def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat retention cleanup job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_chat_retention_cleanup() -> None:
|
||||
"""Delete old conversations for all users according to their retention setting."""
|
||||
from sqlalchemy import select as sa_select # noqa: PLC0415
|
||||
|
||||
from fabledassistant.models.user import User # noqa: PLC0415
|
||||
from fabledassistant.services.chat import cleanup_old_conversations # noqa: PLC0415
|
||||
from fabledassistant.services.settings import get_setting # noqa: PLC0415
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(sa_select(User.id))
|
||||
user_ids = [row[0] for row in result.all()]
|
||||
|
||||
total_deleted = 0
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
retention_str = await get_setting(user_id, "chat_retention_days", "90")
|
||||
try:
|
||||
retention_days = int(retention_str)
|
||||
except (ValueError, TypeError):
|
||||
retention_days = 90
|
||||
if retention_days > 0:
|
||||
deleted = await cleanup_old_conversations(user_id, retention_days)
|
||||
total_deleted += deleted
|
||||
except Exception:
|
||||
logger.warning("Chat retention cleanup failed for user %d", user_id, exc_info=True)
|
||||
|
||||
if total_deleted:
|
||||
logger.info("Chat retention cleanup: deleted %d conversation(s)", total_deleted)
|
||||
|
||||
|
||||
def _run_chat_retention_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_chat_retention_cleanup(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler()
|
||||
|
||||
# Check reminders every 5 minutes
|
||||
_scheduler.add_job(
|
||||
_run_reminders,
|
||||
trigger=IntervalTrigger(minutes=5),
|
||||
args=[loop],
|
||||
id="event_reminders",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# CalDAV pull sync every hour
|
||||
_scheduler.add_job(
|
||||
_run_caldav_sync_threadsafe,
|
||||
trigger=IntervalTrigger(hours=1),
|
||||
args=[loop],
|
||||
id="caldav_pull_sync",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Chat retention cleanup once per day
|
||||
_scheduler.add_job(
|
||||
_run_chat_retention_threadsafe,
|
||||
trigger=IntervalTrigger(hours=24),
|
||||
args=[loop],
|
||||
id="chat_retention_cleanup",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h, chat cleanup every 24h)")
|
||||
|
||||
|
||||
def stop_event_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Event scheduler stopped")
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
@@ -25,9 +26,9 @@ async def create_event(
|
||||
color: str = "",
|
||||
recurrence: str | None = None,
|
||||
project_id: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
# CalDAV-only fields (not stored in DB, forwarded to push)
|
||||
duration: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
attendees: list[str] | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> Event:
|
||||
@@ -46,6 +47,7 @@ async def create_event(
|
||||
color=color,
|
||||
recurrence=recurrence,
|
||||
project_id=project_id,
|
||||
reminder_minutes=reminder_minutes,
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
@@ -74,40 +76,87 @@ async def list_events(
|
||||
user_id: int,
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
) -> list[Event]:
|
||||
"""List events for user_id within [date_from, date_to]."""
|
||||
) -> list[dict]:
|
||||
"""List events for user_id that overlap [date_from, date_to].
|
||||
|
||||
Recurring events (with an RRULE recurrence string) are expanded into
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as Event.to_dict()).
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.start_dt >= date_from,
|
||||
Event.start_dt <= date_to,
|
||||
# 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),
|
||||
),
|
||||
).order_by(Event.start_dt)
|
||||
)
|
||||
return result.scalars().all()
|
||||
events = list(result.scalars().all())
|
||||
|
||||
items: list[dict] = []
|
||||
for event in events:
|
||||
if not event.recurrence:
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
# Expand recurring event occurrences within [date_from, date_to]
|
||||
duration = (event.end_dt - event.start_dt) if event.end_dt else None
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
# Ensure occurrence is UTC-aware
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
|
||||
items.sort(key=lambda x: x["start_dt"])
|
||||
return items
|
||||
|
||||
|
||||
async def search_events(
|
||||
user_id: int,
|
||||
query: str,
|
||||
days_ahead: int = 90,
|
||||
include_past: bool = False,
|
||||
) -> list[Event]:
|
||||
"""Search events by keyword in title, description, or location."""
|
||||
now = datetime.now(timezone.utc)
|
||||
date_to = now + timedelta(days=days_ahead)
|
||||
q = f"%{query}%"
|
||||
async with async_session() as session:
|
||||
where = [
|
||||
Event.user_id == user_id,
|
||||
or_(
|
||||
Event.title.ilike(q),
|
||||
Event.description.ilike(q),
|
||||
Event.location.ilike(q),
|
||||
),
|
||||
]
|
||||
if not include_past:
|
||||
date_to = now + timedelta(days=days_ahead)
|
||||
where.extend([Event.start_dt >= now, Event.start_dt <= date_to])
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.start_dt >= now,
|
||||
Event.start_dt <= date_to,
|
||||
or_(
|
||||
Event.title.ilike(q),
|
||||
Event.description.ilike(q),
|
||||
Event.location.ilike(q),
|
||||
),
|
||||
).order_by(Event.start_dt)
|
||||
select(Event).where(*where).order_by(Event.start_dt)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -123,9 +172,11 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
return None
|
||||
old_title = event.title # capture before mutation for CalDAV lookup
|
||||
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
|
||||
"location", "color", "recurrence", "project_id"}
|
||||
"location", "color", "recurrence", "project_id", "reminder_minutes"}
|
||||
# Nullable fields that callers can explicitly set to None to clear
|
||||
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and value is not None:
|
||||
if key in allowed and (value is not None or key in nullable):
|
||||
setattr(event, key, value)
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
@@ -153,16 +204,32 @@ async def delete_event(user_id: int, event_id: int) -> None:
|
||||
|
||||
|
||||
async def find_events_by_query(user_id: int, query: str) -> list[Event]:
|
||||
"""ILIKE search on title — used by AI update/delete tools."""
|
||||
"""ILIKE search on title — used by AI update/delete tools.
|
||||
|
||||
Returns upcoming events first (start_dt >= now), falling back to
|
||||
past events so the AI operates on the most relevant match.
|
||||
"""
|
||||
q = f"%{query}%"
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
# Prefer events at or after now; fall back to past events
|
||||
upcoming = (await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.title.ilike(q),
|
||||
Event.start_dt >= now,
|
||||
).order_by(Event.start_dt)
|
||||
)
|
||||
return result.scalars().all()
|
||||
)).scalars().all()
|
||||
if upcoming:
|
||||
return list(upcoming)
|
||||
past = (await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.title.ilike(q),
|
||||
Event.start_dt < now,
|
||||
).order_by(Event.start_dt.desc())
|
||||
)).scalars().all()
|
||||
return list(past)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,8 +22,9 @@ from fabledassistant.services.generation_buffer import (
|
||||
GenerationBuffer,
|
||||
GenerationState,
|
||||
)
|
||||
from fabledassistant.services.llm import ChatChunk, build_context, generate_completion, stream_chat, stream_chat_with_tools, summarize_history_for_context, wait_for_model_loaded
|
||||
from fabledassistant.services.llm import ChatChunk, build_context, generate_completion, pick_num_ctx, stream_chat, stream_chat_with_tools, summarize_history_for_context
|
||||
from fabledassistant.services.chat import update_conversation_title
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.services.logging import log_generation
|
||||
from fabledassistant.services.tools import get_tools_for_user, execute_tool
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
@@ -35,6 +36,65 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
|
||||
|
||||
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conditional thinking classifier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Patterns that force think=True even on short messages
|
||||
_THINK_FORCE = re.compile(
|
||||
r"\b("
|
||||
r"analyz|compar|explain\s+why|help\s+me\s+(think|plan|understand|figure\s+out)|"
|
||||
r"step[- ]by[- ]step|debug|troubleshoot|diagnos|"
|
||||
r"pros\s+and\s+cons|trade[- ]?off|"
|
||||
r"architect|design\s+(a|the|my|this)|"
|
||||
r"write\s+a\s+(detailed|long|comprehensive|full)|"
|
||||
r"brainstorm|outline\s+(a|the|my)|"
|
||||
r"what\s+(are|is)\s+the\s+(best|difference|relationship|impact|implication)|"
|
||||
r"how\s+(do|does|should|would|can)\s+.{0,40}\s+work|"
|
||||
r"why\s+(is|are|does|do|did|would|should)\b"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
_WORD_COUNT_THRESHOLD = 60 # messages over this word count always use think=True
|
||||
_SHORT_MESSAGE_THRESHOLD = 12 # messages under this always use think=False
|
||||
|
||||
|
||||
def _should_think(user_content: str, think_requested: bool) -> bool:
|
||||
"""Return whether extended thinking should be used for this request.
|
||||
|
||||
If the caller didn't request thinking, we never enable it. If they did,
|
||||
we check whether the message is complex enough to warrant the overhead.
|
||||
"""
|
||||
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:
|
||||
return True
|
||||
if "```" in text:
|
||||
return True
|
||||
if _THINK_FORCE.search(text):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Human-readable labels for each tool, shown in the status indicator
|
||||
_TOOL_LABELS: dict[str, str] = {
|
||||
"create_task": "Creating task",
|
||||
@@ -57,31 +117,46 @@ _TOOL_LABELS: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
async def _generate_title(messages: list[dict], model: str) -> str:
|
||||
"""Ask the LLM for a concise conversation title."""
|
||||
# Build conversation text like summarize_conversation_as_note
|
||||
conv_lines = []
|
||||
async def _generate_title(messages: list[dict], user_id: int) -> str:
|
||||
"""Ask the LLM for a concise conversation title.
|
||||
|
||||
Only uses user messages to avoid feeding tool-call JSON, system prompt
|
||||
fragments, or other noise into the title generator. Caps input length
|
||||
to keep the task fast and focused.
|
||||
"""
|
||||
user_texts = []
|
||||
for m in messages:
|
||||
if m["role"] == "system":
|
||||
continue
|
||||
label = "User" if m["role"] == "user" else "Assistant"
|
||||
conv_lines.append(f"{label}: {m['content']}")
|
||||
# Keep only last 6 pairs worth of text
|
||||
conv_lines = conv_lines[-12:]
|
||||
if m["role"] == "user":
|
||||
content = (m.get("content") or "").strip()
|
||||
if content:
|
||||
user_texts.append(content[:300])
|
||||
if not user_texts:
|
||||
return ""
|
||||
# First + last user messages capture intent best
|
||||
if len(user_texts) > 2:
|
||||
user_texts = [user_texts[0], user_texts[-1]]
|
||||
|
||||
prompt_messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Generate a concise 3-8 word title for this conversation. "
|
||||
"Reply with ONLY the title, no quotes or punctuation."
|
||||
"Generate a concise 3-8 word title for a conversation that started with:\n\n"
|
||||
+ "\n\n".join(user_texts)
|
||||
+ "\n\nReply with ONLY the title. No quotes, no punctuation, no explanation."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "\n\n".join(conv_lines)},
|
||||
]
|
||||
title = await generate_completion(prompt_messages, model, max_tokens=30)
|
||||
bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
|
||||
title = await generate_completion(prompt_messages, bg_model, max_tokens=30, num_ctx=1024)
|
||||
# Strip common LLM noise: quotes, thinking tags, role labels
|
||||
title = title.strip().strip('"\'').strip()
|
||||
return title[:100] if title else ""
|
||||
for prefix in ("Title:", "title:", "Assistant:", "User:"):
|
||||
if title.startswith(prefix):
|
||||
title = title[len(prefix):].strip()
|
||||
# Drop anything after a newline (model sometimes adds explanation)
|
||||
if "\n" in title:
|
||||
title = title.split("\n")[0].strip()
|
||||
return title[:80] if title else ""
|
||||
|
||||
|
||||
async def _update_message(
|
||||
@@ -107,6 +182,7 @@ async def _stream_with_retry(
|
||||
model: str,
|
||||
tools: list[dict],
|
||||
think: bool,
|
||||
num_ctx: int | None = None,
|
||||
) -> AsyncGenerator[ChatChunk, None]:
|
||||
"""stream_chat_with_tools with automatic retry on Ollama 500 errors.
|
||||
|
||||
@@ -123,7 +199,7 @@ async def _stream_with_retry(
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
async for chunk in stream_chat_with_tools(messages, model, tools=tools, think=think):
|
||||
async for chunk in stream_chat_with_tools(messages, model, tools=tools, think=think, num_ctx=num_ctx):
|
||||
yield chunk
|
||||
return
|
||||
except httpx.HTTPStatusError as exc:
|
||||
@@ -155,7 +231,7 @@ async def run_generation(
|
||||
voice_mode: bool = False,
|
||||
) -> None:
|
||||
"""Stream LLM response into buffer with periodic DB flushes."""
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
MAX_TOOL_ROUNDS = 6
|
||||
msg_id = buf.assistant_message_id
|
||||
|
||||
buf.append_event("status", {"status": "Building context..."})
|
||||
@@ -175,8 +251,10 @@ async def run_generation(
|
||||
buf.append_event("status", {"status": "Summarizing conversation history..."})
|
||||
history_to_use, history_summary = await summarize_history_for_context(history, model)
|
||||
|
||||
# Phase 3: Build context and wait for model in parallel.
|
||||
model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=180.0))
|
||||
# Phase 3: Build context.
|
||||
# Note: Ollama lazy-loads models on the first /api/chat request, so polling
|
||||
# /api/ps for model readiness only causes delay. We proceed immediately and
|
||||
# let Ollama handle loading on demand.
|
||||
|
||||
# Fetch voice_speech_style from user settings when voice_mode is active.
|
||||
voice_speech_style = "conversational"
|
||||
@@ -184,7 +262,7 @@ async def run_generation(
|
||||
from fabledassistant.services.settings import get_setting
|
||||
voice_speech_style = await get_setting(user_id, "voice_speech_style", "conversational")
|
||||
|
||||
context_task = asyncio.create_task(build_context(
|
||||
messages, context_meta = await build_context(
|
||||
user_id, history_to_use, context_note_id, user_content,
|
||||
history_summary=history_summary,
|
||||
include_note_ids=include_note_ids,
|
||||
@@ -195,24 +273,27 @@ async def run_generation(
|
||||
conv_id=conv_id,
|
||||
voice_mode=voice_mode,
|
||||
voice_speech_style=voice_speech_style,
|
||||
))
|
||||
)
|
||||
|
||||
messages, context_meta = await context_task
|
||||
# Pick the smallest context tier that fits the current messages.
|
||||
# Using the minimum needed tier reduces KV cache size and speeds up prefill.
|
||||
num_ctx = pick_num_ctx(messages)
|
||||
logger.debug("Adaptive num_ctx=%d for conv %d", num_ctx, conv_id)
|
||||
|
||||
# Emit context event
|
||||
buf.append_event("context", {"context": context_meta})
|
||||
|
||||
# Wait for main model to be loaded before starting any generation.
|
||||
# If it's already loaded (common case), this returns immediately.
|
||||
if not model_load_task.done():
|
||||
buf.append_event("status", {"status": "Loading model..."})
|
||||
loaded = await model_load_task
|
||||
if not loaded:
|
||||
logger.warning("Model %s did not load within 180s — proceeding anyway", model)
|
||||
# Apply thinking classifier — downgrade think=True for simple/conversational messages
|
||||
think = _should_think(user_content, think)
|
||||
|
||||
t_start = time.monotonic()
|
||||
timing: dict = {
|
||||
"think": think,
|
||||
"num_ctx": num_ctx,
|
||||
"tools": [],
|
||||
"rounds": 0,
|
||||
"prompt_tokens": None,
|
||||
"output_tokens": None,
|
||||
"ttft_ms": None,
|
||||
"generation_ms": None,
|
||||
"total_ms": None,
|
||||
@@ -227,7 +308,8 @@ async def run_generation(
|
||||
cancelled = False
|
||||
research_completed = False
|
||||
|
||||
for _round in range(MAX_TOOL_ROUNDS + 1):
|
||||
for _round in range(MAX_TOOL_ROUNDS):
|
||||
timing["rounds"] = _round + 1
|
||||
round_tool_calls: list[dict] = []
|
||||
logger.info("Generation round %d started for conv %d (model=%s)", _round, conv_id, model)
|
||||
|
||||
@@ -237,7 +319,7 @@ async def run_generation(
|
||||
buf.append_event("status", {"status": "Generating response..." if _round == 0 else "Composing response..."})
|
||||
t_stream = time.monotonic()
|
||||
|
||||
async for chunk in _stream_with_retry(messages, model, tools, think):
|
||||
async for chunk in _stream_with_retry(messages, model, tools, think, num_ctx=num_ctx):
|
||||
if buf.cancel_event.is_set():
|
||||
cancelled = True
|
||||
break
|
||||
@@ -261,6 +343,12 @@ async def run_generation(
|
||||
logger.warning("Failed periodic flush for message %d", msg_id, exc_info=True)
|
||||
last_flush = now
|
||||
|
||||
elif chunk.type == "done":
|
||||
if chunk.prompt_tokens is not None:
|
||||
timing["prompt_tokens"] = (timing["prompt_tokens"] or 0) + chunk.prompt_tokens
|
||||
if chunk.output_tokens is not None:
|
||||
timing["output_tokens"] = (timing["output_tokens"] or 0) + chunk.output_tokens
|
||||
|
||||
elif chunk.type == "tool_calls" and chunk.tool_calls:
|
||||
logger.info("Round %d: model returned %d tool call(s)", _round, len(chunk.tool_calls))
|
||||
for tc in chunk.tool_calls:
|
||||
@@ -418,7 +506,7 @@ async def run_generation(
|
||||
|
||||
async def _bg_title() -> None:
|
||||
try:
|
||||
title = await _generate_title(title_messages, model)
|
||||
title = await _generate_title(title_messages, user_id)
|
||||
if title:
|
||||
await update_conversation_title(user_id, conv_id, title)
|
||||
except Exception:
|
||||
@@ -455,8 +543,10 @@ async def run_assist_generation(
|
||||
On each retry the accumulated content is reset so the done event
|
||||
always reflects only the successful generation.
|
||||
"""
|
||||
from fabledassistant.services.llm import pick_num_ctx
|
||||
input_chars = sum(len(m.get("content", "")) for m in messages)
|
||||
logger.info("Assist generation started: model=%s, input_chars=%d", model, input_chars)
|
||||
num_ctx = pick_num_ctx(messages)
|
||||
logger.info("Assist generation started: model=%s, input_chars=%d, num_ctx=%d", model, input_chars, num_ctx)
|
||||
|
||||
last_exc: BaseException | None = None
|
||||
for attempt in range(3):
|
||||
@@ -468,7 +558,7 @@ async def run_assist_generation(
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
buf.content_so_far = ""
|
||||
async for chunk in stream_chat(messages, model, options={"num_predict": Config.OLLAMA_NUM_CTX}):
|
||||
async for chunk in stream_chat(messages, model, options={"num_predict": num_ctx}, num_ctx=num_ctx):
|
||||
buf.content_so_far += chunk
|
||||
buf.append_event("chunk", {"chunk": chunk})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user