chore(docs): retire dead fable-mcp wheel-distribution refs; untrack docs/superpowers
The old standalone fable-mcp wheel/download flow is gone from code (no route, no Dockerfile build, no FABLE_MCP_DIST_DIR). Update api-keys-and-mcp, api-reference, architecture, configuration, development to describe the in-app HTTP MCP at /mcp (Bearer auth). Untrack the 18 committed docs/superpowers/ files so the existing .gitignore takes effect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+49
-94
@@ -1,4 +1,4 @@
|
|||||||
# API Keys and Fable MCP
|
# API Keys and Scribe MCP
|
||||||
|
|
||||||
## API Keys
|
## API Keys
|
||||||
|
|
||||||
@@ -19,11 +19,10 @@ Admin-level operations (log access, user management) require a `write`-scoped ke
|
|||||||
2. Enter a name (e.g. "Claude MCP", "Home Server")
|
2. Enter a name (e.g. "Claude MCP", "Home Server")
|
||||||
3. Choose scope
|
3. Choose scope
|
||||||
4. Click **Generate Key**
|
4. Click **Generate Key**
|
||||||
5. Copy the key immediately — it is shown only once
|
5. Copy the key immediately — it is shown only once (the token is `fmcp_`-prefixed)
|
||||||
|
|
||||||
After creation you can download:
|
Paste the key into the `Authorization: Bearer <key>` header of your MCP client
|
||||||
- **`.env` file** — `FABLE_URL` + `FABLE_API_KEY` ready to paste
|
config (see **Scribe MCP Server** below).
|
||||||
- **Claude config JSON** — `mcpServers` block ready to merge into `~/.claude.json`
|
|
||||||
|
|
||||||
### Revoking a Key
|
### Revoking a Key
|
||||||
|
|
||||||
@@ -31,73 +30,35 @@ Click **Revoke** next to the key in the API Keys table and confirm. Revoked keys
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fable MCP Server
|
## Scribe MCP Server
|
||||||
|
|
||||||
The Fable MCP server (`fable-mcp`) exposes Fable as a set of MCP tools that Claude (and other MCP clients) can use to read and write your notes, tasks, projects, and more.
|
Scribe exposes itself as a set of MCP tools that Claude (and other MCP clients)
|
||||||
|
can use to read and write your notes, tasks, projects, rulebooks, and more. The
|
||||||
|
server is **built into the app** — it is mounted as a streamable-HTTP endpoint
|
||||||
|
at **`/mcp`** on the running Scribe instance (`src/scribe/mcp/server.py`). There
|
||||||
|
is nothing to install: no wheel, no separate package, no CLI. You connect a
|
||||||
|
client straight to the URL with a Bearer token.
|
||||||
|
|
||||||
### Download
|
### Authentication
|
||||||
|
|
||||||
The wheel is bundled into the Docker image at build time and available for download from **Settings → API Keys → Fable MCP** when you are logged in.
|
Authenticate with an API key generated from **Settings → API Keys** (see above),
|
||||||
|
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
|
||||||
You can also download it directly:
|
read tools (`get_*`, `list_*`, `search`, `enter_project`); any write/delete tool
|
||||||
```
|
is rejected with `403`. A `write`-scoped key may call everything.
|
||||||
GET /api/fable-mcp/download
|
|
||||||
```
|
|
||||||
(Requires login — authenticated browser session or API key in `Authorization: Bearer <key>` header.)
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install the wheel
|
|
||||||
pip install fable_mcp-*.whl
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
fable-mcp --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
The server reads two environment variables:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `FABLE_URL` | Base URL of your Fable instance (e.g. `https://notes.example.com`) |
|
|
||||||
| `FABLE_API_KEY` | API key generated from Settings → API Keys |
|
|
||||||
|
|
||||||
Create a `.env` file in your working directory, or set them in your shell / MCP config.
|
|
||||||
|
|
||||||
### Claude Code (Global)
|
|
||||||
|
|
||||||
Add to `~/.claude.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"fable": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "fable-mcp",
|
|
||||||
"env": {
|
|
||||||
"FABLE_URL": "https://your-fable-instance.example.com",
|
|
||||||
"FABLE_API_KEY": "your-api-key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Claude Code (Project-scoped)
|
### Claude Code (Project-scoped)
|
||||||
|
|
||||||
Add a `.mcp.json` at the project root (same format as the global config). Project-scoped config takes precedence over global when the same server name is defined in both. This is useful for using a dev instance or admin key within a specific project.
|
Add a `.mcp.json` at the project root. The server `type` is `http` and the URL is
|
||||||
|
your instance's `/mcp` endpoint:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"fable": {
|
"scribe": {
|
||||||
"type": "stdio",
|
"type": "http",
|
||||||
"command": "fable-mcp",
|
"url": "https://your-scribe-instance.example.com/mcp",
|
||||||
"env": {
|
"headers": {
|
||||||
"FABLE_URL": "http://localhost:5000",
|
"Authorization": "Bearer fmcp_your-api-key"
|
||||||
"FABLE_API_KEY": "your-dev-api-key"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,39 +67,33 @@ Add a `.mcp.json` at the project root (same format as the global config). Projec
|
|||||||
|
|
||||||
Note: `.mcp.json` contains an API key and should be added to `.gitignore`.
|
Note: `.mcp.json` contains an API key and should be added to `.gitignore`.
|
||||||
|
|
||||||
|
### Claude Code (Global)
|
||||||
|
|
||||||
|
The same `mcpServers` block can live in `~/.claude.json` to make the server
|
||||||
|
available across all projects. A project-scoped `.mcp.json` takes precedence over
|
||||||
|
the global entry when both define the same server name — useful for pointing a
|
||||||
|
specific project at a dev instance or an admin key.
|
||||||
|
|
||||||
### Available Tools
|
### Available Tools
|
||||||
|
|
||||||
| Tool | Description |
|
The tool surface is large (~70 tools) and evolves with the app, so the live
|
||||||
|------|-------------|
|
registration in **`src/scribe/mcp/tools/`** is the source of truth rather than a
|
||||||
| `fable_list_notes` | List notes, filter by tag or search text |
|
table here. The tools are grouped by family:
|
||||||
| `fable_get_note` | Fetch a note by ID |
|
|
||||||
| `fable_create_note` | Create a new note |
|
|
||||||
| `fable_update_note` | Update a note |
|
|
||||||
| `fable_delete_note` | Delete a note |
|
|
||||||
| `fable_list_tasks` | List tasks, filter by status or project |
|
|
||||||
| `fable_get_task` | Fetch a task by ID |
|
|
||||||
| `fable_create_task` | Create a new task |
|
|
||||||
| `fable_update_task` | Update a task |
|
|
||||||
| `fable_add_task_log` | Append a work log entry to a task |
|
|
||||||
| `fable_list_projects` | List all projects |
|
|
||||||
| `fable_get_project` | Fetch a project with milestone summary |
|
|
||||||
| `fable_create_project` | Create a project |
|
|
||||||
| `fable_update_project` | Update a project |
|
|
||||||
| `fable_list_milestones` | List milestones for a project |
|
|
||||||
| `fable_create_milestone` | Create a milestone |
|
|
||||||
| `fable_update_milestone` | Update a milestone |
|
|
||||||
| `fable_search` | Semantic search over notes and tasks |
|
|
||||||
| `fable_list_conversations` | List MCP chat conversations |
|
|
||||||
| `fable_send_message` | Send a message to Fable's LLM |
|
|
||||||
| `fable_get_app_logs` | Fetch application logs (admin key required) |
|
|
||||||
|
|
||||||
### Development Notes
|
| Family | Examples | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| Notes | `create_note`, `get_note`, `update_note`, `delete_note`, `list_notes` | Free-form knowledge |
|
||||||
|
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
||||||
|
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
||||||
|
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
|
||||||
|
| Typed entities | `create_person`, `create_place`, `create_list`, … | Structured records |
|
||||||
|
| Events | `create_event`, `list_events`, `update_event`, … | Calendar |
|
||||||
|
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
|
||||||
|
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||||
|
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
||||||
|
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
|
||||||
|
|
||||||
The `fable-mcp` package lives in `fable-mcp/` in this repository. The Docker build compiles it into a wheel at `/app/dist/` so it can be served for download without requiring the source tree at runtime.
|
Server-level usage guidance — when to reach for each entity, the
|
||||||
|
recall-before-acting reflex, and the rulebook conventions — is delivered to the
|
||||||
To build the wheel locally:
|
client automatically via the MCP server's `instructions` block (defined in
|
||||||
```bash
|
`src/scribe/mcp/server.py`).
|
||||||
cd fable-mcp
|
|
||||||
pip install build hatchling
|
|
||||||
python -m build --wheel .
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -179,12 +179,11 @@ All endpoints require login (session cookie or `Authorization: Bearer <api-key>`
|
|||||||
| POST | `/api/api-keys` | Create key `{name, scope}` → `{key, ...}` (key shown once) |
|
| POST | `/api/api-keys` | Create key `{name, scope}` → `{key, ...}` (key shown once) |
|
||||||
| DELETE | `/api/api-keys/:id` | Revoke key |
|
| DELETE | `/api/api-keys/:id` | Revoke key |
|
||||||
|
|
||||||
## Fable MCP Distribution
|
## Scribe MCP
|
||||||
|
|
||||||
| Method | Path | Description |
|
The MCP tool surface is served at `POST /mcp` (streamable HTTP, Bearer auth) by
|
||||||
|--------|------|-------------|
|
the in-app server in `src/scribe/mcp/`. It is not a REST surface — see
|
||||||
| GET | `/api/fable-mcp/info` | `{available: bool, filename: string\|null}` |
|
[API Keys and Scribe MCP](api-keys-and-mcp.md) for client configuration.
|
||||||
| GET | `/api/fable-mcp/download` | Download wheel file |
|
|
||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
|
|||||||
@@ -49,16 +49,14 @@ scribe/
|
|||||||
├── Dockerfile # Multi-stage build (Node → Python)
|
├── Dockerfile # Multi-stage build (Node → Python)
|
||||||
├── alembic/ # Database migrations
|
├── alembic/ # Database migrations
|
||||||
│ └── versions/ # Migration files (idempotent raw SQL)
|
│ └── versions/ # Migration files (idempotent raw SQL)
|
||||||
├── fable-mcp/ # Fable MCP server package
|
|
||||||
│ └── fable_mcp/
|
|
||||||
│ ├── server.py # FastMCP tool registrations
|
|
||||||
│ ├── client.py # FableClient (httpx wrapper)
|
|
||||||
│ └── tools/ # Tool modules (notes, tasks, projects, …)
|
|
||||||
├── src/scribe/
|
├── src/scribe/
|
||||||
│ ├── app.py # Quart app factory + blueprint registration
|
│ ├── app.py # Quart app factory + blueprint registration
|
||||||
│ ├── config.py # Config class (reads env vars)
|
│ ├── config.py # Config class (reads env vars)
|
||||||
│ ├── auth.py # login_required decorator, session checks
|
│ ├── auth.py # login_required decorator, session checks
|
||||||
│ ├── models/ # SQLAlchemy models
|
│ ├── models/ # SQLAlchemy models
|
||||||
|
│ ├── mcp/ # In-app MCP server (FastMCP, mounted at /mcp)
|
||||||
|
│ │ ├── server.py # FastMCP instance + instructions + Quart mount
|
||||||
|
│ │ └── tools/ # Tool modules (notes, tasks, projects, rulebooks, …)
|
||||||
│ ├── routes/ # API blueprints (one file per resource)
|
│ ├── routes/ # API blueprints (one file per resource)
|
||||||
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
|
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
|
||||||
│ └── static/ # Built Vue SPA (generated at Docker build time)
|
│ └── static/ # Built Vue SPA (generated at Docker build time)
|
||||||
@@ -202,7 +200,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
|||||||
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
|
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
|
||||||
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
|
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
|
||||||
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
|
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
|
||||||
| `routes/fable_mcp_dist.py` | `GET /api/fable-mcp/info` + `GET /api/fable-mcp/download` — package distribution |
|
| `mcp/server.py` | Mounts the in-app FastMCP server at `/mcp` (streamable HTTP, Bearer auth) |
|
||||||
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
|
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
|
||||||
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
|
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
|
||||||
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
|
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
|
||||||
|
|||||||
@@ -58,12 +58,6 @@ See [sso-oauth.md](sso-oauth.md) for provider-specific setup.
|
|||||||
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
|
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
|
||||||
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
|
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
|
||||||
|
|
||||||
### Fable MCP Distribution
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `FABLE_MCP_DIST_DIR` | `/app/dist` | Directory where the bundled `fable-mcp` wheel is placed at build time |
|
|
||||||
|
|
||||||
## Docker Compose Setup
|
## Docker Compose Setup
|
||||||
|
|
||||||
### Development (`docker-compose.yml`)
|
### Development (`docker-compose.yml`)
|
||||||
|
|||||||
+1
-1
@@ -164,7 +164,7 @@ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
|
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
|
||||||
Scopes: feature area (e.g. `chat`, `briefing`, `fable-mcp`, `notes`)
|
Scopes: feature area (e.g. `chat`, `journal`, `mcp`, `notes`)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,594 +0,0 @@
|
|||||||
# 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 ✅
|
|
||||||
@@ -1,695 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
# 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"
|
|
||||||
```
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
# 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"
|
|
||||||
```
|
|
||||||
@@ -1,786 +0,0 @@
|
|||||||
# Specialized Note Type Editors — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Replace the generic note editor with type-specialized form-first views for Person, Place, and List, and fix tab navigation so focus flows logically from title through content fields, skipping the formatting toolbar.
|
|
||||||
|
|
||||||
**Architecture:** `NoteEditorView.vue` gains type-conditional template sections. When `noteType` is `person` or `place`, the main editor area renders a structured form with the TipTap editor in a secondary "Notes" section. When `noteType` is `list`, a dedicated list builder replaces TipTap as the primary interface. `MarkdownToolbar.vue` gets `tabindex="-1"` on buttons. Backend `_note_to_item` gains new person/place fields.
|
|
||||||
|
|
||||||
**Tech Stack:** Vue 3 Composition API, TipTap editor, TypeScript, scoped CSS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path |
|
|
||||||
|--------|------|
|
|
||||||
| Modify | `frontend/src/views/NoteEditorView.vue` |
|
|
||||||
| Modify | `frontend/src/components/MarkdownToolbar.vue` |
|
|
||||||
| Modify | `frontend/src/views/KnowledgeView.vue` |
|
|
||||||
| Modify | `src/fabledassistant/services/knowledge.py` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Tab navigation fix — toolbar tabindex + auto-focus
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/components/MarkdownToolbar.vue`
|
|
||||||
- Modify: `frontend/src/views/NoteEditorView.vue`
|
|
||||||
|
|
||||||
**Context:** The MarkdownToolbar renders buttons via `v-for` in a single `<button>` element. Adding `tabindex="-1"` removes them from tab order while keeping them clickable. The NoteEditorView already has a `titleRef` — auto-focus on mount needs to call `.focus()` on it. The title placeholder should vary by note type.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add tabindex="-1" to toolbar buttons**
|
|
||||||
|
|
||||||
In `frontend/src/components/MarkdownToolbar.vue`, find:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button
|
|
||||||
v-for="btn in group"
|
|
||||||
:key="btn.id"
|
|
||||||
:class="['md-btn', { active: btn.isActive() }]"
|
|
||||||
:title="btn.title"
|
|
||||||
type="button"
|
|
||||||
@mousedown.prevent="btn.command()"
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button
|
|
||||||
v-for="btn in group"
|
|
||||||
:key="btn.id"
|
|
||||||
:class="['md-btn', { active: btn.isActive() }]"
|
|
||||||
:title="btn.title"
|
|
||||||
type="button"
|
|
||||||
tabindex="-1"
|
|
||||||
@mousedown.prevent="btn.command()"
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add auto-focus on mount and type-dependent placeholder**
|
|
||||||
|
|
||||||
In `frontend/src/views/NoteEditorView.vue`, find the title input:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<input
|
|
||||||
ref="titleRef"
|
|
||||||
v-model="title"
|
|
||||||
type="text"
|
|
||||||
placeholder="Title"
|
|
||||||
class="title-input"
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<input
|
|
||||||
ref="titleRef"
|
|
||||||
v-model="title"
|
|
||||||
type="text"
|
|
||||||
:placeholder="titlePlaceholder"
|
|
||||||
class="title-input"
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the computed property in the `<script setup>` section, after the `isEditing` computed:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const titlePlaceholder = computed(() => {
|
|
||||||
switch (noteType.value) {
|
|
||||||
case 'person': return 'Name';
|
|
||||||
case 'place': return 'Place name';
|
|
||||||
case 'list': return 'List title';
|
|
||||||
default: return 'Title';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Auto-focus title on mount**
|
|
||||||
|
|
||||||
In the `onMounted` callback, after all the data loading logic (after the draft restore try/catch block), add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
await nextTick();
|
|
||||||
titleRef.value?.focus();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/components/MarkdownToolbar.vue frontend/src/views/NoteEditorView.vue
|
|
||||||
git commit -m "feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Person editor — form-first layout
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/views/NoteEditorView.vue`
|
|
||||||
|
|
||||||
**Context:** When `noteType === 'person'`, the main content area should render a contact card form instead of the TipTap-first editor. The person metadata fields (currently in the sidebar) move to the main area, and new fields (birthday, organization, address) are added. The TipTap editor becomes a collapsible "Notes" section below. The sidebar keeps project/tags/type/etc but loses the person-specific fields.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the person form template**
|
|
||||||
|
|
||||||
In the template, find the `<!-- ── Main column ──` section. The current structure is:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
|
||||||
<div class="body-tabs-row">
|
|
||||||
...
|
|
||||||
</div>
|
|
||||||
<!-- Streaming/Review/Normal editor templates -->
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Wrap the existing main column content in a `v-if="noteType === 'note'"` (and also show it for any type not person/place/list), and add a person form block. Replace the opening of the main column content:
|
|
||||||
|
|
||||||
Find the `<div class="note-main"` line and the content inside it up to `</div>` that closes `.note-main`. Wrap all existing content inside:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="note-main">
|
|
||||||
<!-- ── Person form ──────────────────────────────────────── -->
|
|
||||||
<template v-if="noteType === 'person'">
|
|
||||||
<div class="entity-form">
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Relationship</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Birthday</label>
|
|
||||||
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Email</label>
|
|
||||||
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Phone</label>
|
|
||||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Organization</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Address</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, wikilinks, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── Generic note editor (existing) ───────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'note'">
|
|
||||||
<!-- ... existing TipTap-first editor content stays here ... -->
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
IMPORTANT: Do NOT duplicate the existing editor content. Wrap the existing content in `<template v-else-if="noteType === 'note'">` and place the person form as a sibling `<template>` above it. The place and list forms will be added in subsequent tasks.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `notesExpanded` ref**
|
|
||||||
|
|
||||||
In the `<script setup>`, after the `sidebarOpen` ref, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const notesExpanded = ref(false);
|
|
||||||
```
|
|
||||||
|
|
||||||
Also initialize it based on whether the note has body content, in the onMounted data-loading section. After `Object.assign(entityMeta, store.currentNote.metadata || {});` add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
notesExpanded.value = !!(store.currentNote.body || '').trim();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Remove person fields from sidebar**
|
|
||||||
|
|
||||||
In the sidebar template, find:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Person metadata -->
|
|
||||||
<template v-if="noteType === 'person'">
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Relationship</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Email</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Phone</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
Delete this entire block.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add entity form CSS**
|
|
||||||
|
|
||||||
Add to the `<style scoped>` block:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* ── Entity form (Person / Place) ───────────────────────── */
|
|
||||||
.entity-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
.ef-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.ef-label {
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.ef-input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.ef-input:focus {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
|
||||||
.ef-input::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Notes section (collapsible TipTap) ─────────────────── */
|
|
||||||
.notes-section {
|
|
||||||
margin-top: 16px;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
padding-top: 12px;
|
|
||||||
}
|
|
||||||
.notes-toggle {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.notes-toggle:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.notes-editor-wrap {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/views/NoteEditorView.vue
|
|
||||||
git commit -m "feat(editor): person form-first layout with structured fields and collapsible notes"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Place editor + List builder
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/views/NoteEditorView.vue`
|
|
||||||
|
|
||||||
**Context:** Place uses the same entity form pattern as Person with different fields. List uses a dedicated checklist builder with Enter-to-add and Backspace-to-delete behavior. Both are additional `<template>` branches in the main column.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add place form template**
|
|
||||||
|
|
||||||
In the `note-main` div, after the person `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- ── Place form ───────────────────────────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'place'">
|
|
||||||
<div class="entity-form">
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Address</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Phone</label>
|
|
||||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Hours</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9am–5pm" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Website</label>
|
|
||||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Category</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, wikilinks, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Remove place fields from sidebar**
|
|
||||||
|
|
||||||
Find and delete:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Place metadata -->
|
|
||||||
<template v-if="noteType === 'place'">
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Address</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Phone</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Hours</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9–5" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add list item types and state**
|
|
||||||
|
|
||||||
In the `<script setup>`, after the `notesExpanded` ref, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ── List builder ─────────────────────────────────────────────────────────────
|
|
||||||
interface ListItem {
|
|
||||||
text: string;
|
|
||||||
checked: boolean;
|
|
||||||
}
|
|
||||||
const listItems = ref<ListItem[]>([]);
|
|
||||||
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
|
|
||||||
|
|
||||||
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
|
|
||||||
const lines = bodyText.split('\n');
|
|
||||||
const items: ListItem[] = [];
|
|
||||||
const extraLines: string[] = [];
|
|
||||||
let pastList = false;
|
|
||||||
for (const line of lines) {
|
|
||||||
const stripped = line.trimStart();
|
|
||||||
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
|
|
||||||
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
|
|
||||||
} else if (!pastList && stripped === '' && items.length > 0) {
|
|
||||||
pastList = true;
|
|
||||||
} else {
|
|
||||||
pastList = true;
|
|
||||||
extraLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { items, extra: extraLines.join('\n').trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeListToBody(): string {
|
|
||||||
const listPart = listItems.value
|
|
||||||
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
|
||||||
.join('\n');
|
|
||||||
const extraPart = body.value.trim();
|
|
||||||
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addListItem(afterIndex?: number) {
|
|
||||||
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
|
|
||||||
listItems.value.splice(idx, 0, { text: '', checked: false });
|
|
||||||
markDirty();
|
|
||||||
nextTick(() => {
|
|
||||||
listItemRefs.value[idx]?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeListItem(index: number) {
|
|
||||||
if (listItems.value.length <= 1) return;
|
|
||||||
listItems.value.splice(index, 1);
|
|
||||||
markDirty();
|
|
||||||
nextTick(() => {
|
|
||||||
const focusIdx = Math.max(0, index - 1);
|
|
||||||
listItemRefs.value[focusIdx]?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onListItemKeydown(e: KeyboardEvent, index: number) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
addListItem(index);
|
|
||||||
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
|
|
||||||
e.preventDefault();
|
|
||||||
removeListItem(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onListItemInput(index: number) {
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleListItemCheck(index: number) {
|
|
||||||
listItems.value[index].checked = !listItems.value[index].checked;
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Initialize list items on mount**
|
|
||||||
|
|
||||||
In the onMounted data-loading section, after `notesExpanded.value = !!(store.currentNote.body || '').trim();`, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (noteType.value === 'list') {
|
|
||||||
const parsed = parseListFromBody(body.value);
|
|
||||||
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
|
|
||||||
body.value = parsed.extra;
|
|
||||||
notesExpanded.value = !!parsed.extra;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And in the new-note branch (the `else` block after loading), after `noteType.value = qt as NoteType;`, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (noteType.value === 'list') {
|
|
||||||
listItems.value = [{ text: '', checked: false }];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update save to serialize list**
|
|
||||||
|
|
||||||
In the `save` function, find where the body is prepared for the API call. Before the `apiPost` or `apiPatch` call that sends the note data, add list serialization. Find the save function's data construction. Add before the API call:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
|
||||||
```
|
|
||||||
|
|
||||||
Then use `finalBody` instead of `body.value` in the API payload. Find all occurrences of `body: body.value` in the save function and replace with `body: finalBody`.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add list builder template**
|
|
||||||
|
|
||||||
In the `note-main` div, after the place `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- ── List builder ─────────────────────────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'list'">
|
|
||||||
<div class="list-builder">
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in listItems"
|
|
||||||
:key="idx"
|
|
||||||
class="lb-item"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="item.checked"
|
|
||||||
@change="toggleListItemCheck(idx)"
|
|
||||||
class="lb-check"
|
|
||||||
tabindex="-1"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
|
||||||
v-model="item.text"
|
|
||||||
class="lb-text"
|
|
||||||
placeholder="List item..."
|
|
||||||
@keydown="onListItemKeydown($event, idx)"
|
|
||||||
@input="onListItemInput(idx)"
|
|
||||||
/>
|
|
||||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
|
||||||
</div>
|
|
||||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Add list builder CSS**
|
|
||||||
|
|
||||||
Add to the `<style scoped>` block:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* ── List builder ───────────────────────────────────────── */
|
|
||||||
.list-builder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
.lb-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.lb-check {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.lb-text {
|
|
||||||
flex: 1;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.lb-text:focus {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
|
||||||
.lb-text::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.lb-delete {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.lb-item:hover .lb-delete,
|
|
||||||
.lb-text:focus ~ .lb-delete {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.lb-delete:hover {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.lb-add {
|
|
||||||
background: none;
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 4px;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.lb-add:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Handle the generic note template wrapper**
|
|
||||||
|
|
||||||
Make sure the existing TipTap-first editor content is wrapped in `<template v-else>` (not `v-else-if="noteType === 'note'"`) so it serves as the default for any unrecognized type.
|
|
||||||
|
|
||||||
The final structure in `.note-main` should be:
|
|
||||||
|
|
||||||
```
|
|
||||||
<template v-if="noteType === 'person'"> ... </template>
|
|
||||||
<template v-else-if="noteType === 'place'"> ... </template>
|
|
||||||
<template v-else-if="noteType === 'list'"> ... </template>
|
|
||||||
<template v-else> ... existing TipTap editor ... </template>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 9: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 10: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/views/NoteEditorView.vue
|
|
||||||
git commit -m "feat(editor): place form-first layout and list builder with Enter-to-add"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Backend — new person/place fields in knowledge cards
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/knowledge.py`
|
|
||||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
|
||||||
|
|
||||||
**Context:** The knowledge card display should show the new fields (birthday, organization for person; website, category for place). The backend `_note_to_item` needs to include them. The frontend card rendering needs to display the useful ones.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update `_note_to_item` for person**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/knowledge.py`, find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if note.entity_type == "person":
|
|
||||||
item["relationship"] = meta.get("relationship", "")
|
|
||||||
item["email"] = meta.get("email", "")
|
|
||||||
item["phone"] = meta.get("phone", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if note.entity_type == "person":
|
|
||||||
item["relationship"] = meta.get("relationship", "")
|
|
||||||
item["email"] = meta.get("email", "")
|
|
||||||
item["phone"] = meta.get("phone", "")
|
|
||||||
item["birthday"] = meta.get("birthday", "")
|
|
||||||
item["organization"] = meta.get("organization", "")
|
|
||||||
item["address"] = meta.get("address", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update `_note_to_item` for place**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif note.entity_type == "place":
|
|
||||||
item["address"] = meta.get("address", "")
|
|
||||||
item["phone"] = meta.get("phone", "")
|
|
||||||
item["hours"] = meta.get("hours", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif note.entity_type == "place":
|
|
||||||
item["address"] = meta.get("address", "")
|
|
||||||
item["phone"] = meta.get("phone", "")
|
|
||||||
item["hours"] = meta.get("hours", "")
|
|
||||||
item["website"] = meta.get("website", "")
|
|
||||||
item["category"] = meta.get("category", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update KnowledgeItem interface**
|
|
||||||
|
|
||||||
In `frontend/src/views/KnowledgeView.vue`, find the `KnowledgeItem` interface and add the new fields:
|
|
||||||
|
|
||||||
After `phone?: string;` add:
|
|
||||||
```ts
|
|
||||||
birthday?: string;
|
|
||||||
organization?: string;
|
|
||||||
```
|
|
||||||
|
|
||||||
After `hours?: string;` add:
|
|
||||||
```ts
|
|
||||||
website?: string;
|
|
||||||
category?: string;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update person card display**
|
|
||||||
|
|
||||||
In the template, find the person card specifics:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
|
||||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
|
||||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
|
||||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
|
||||||
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
|
|
||||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update place card display**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
|
||||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
|
||||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
|
||||||
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
|
|
||||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
|
||||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Verify TypeScript compiles and backend syntax**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
python -c "import ast; ast.parse(open('src/fabledassistant/services/knowledge.py').read()); print('OK')"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/knowledge.py frontend/src/views/KnowledgeView.vue
|
|
||||||
git commit -m "feat(knowledge): show organization/birthday for person cards, category for place cards"
|
|
||||||
```
|
|
||||||
@@ -1,785 +0,0 @@
|
|||||||
# 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"
|
|
||||||
```
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
# Unified Lookup Tool & Wikipedia Integration — 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 `search_web` with a unified `lookup` tool (Wikipedia-first, SearXNG fallback) and add Wikipedia as a source in the research pipeline.
|
|
||||||
|
|
||||||
**Architecture:** New `wikipedia.py` service with `wiki_summary` and `wiki_search`. `lookup` tool in `web.py` replaces `search_web`. Research pipeline in `research.py` gains Wikipedia sources alongside SearXNG. All `search_web` references across the codebase are updated.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.12, httpx, pytest, asyncio
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Wikipedia Service Module
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/fabledassistant/services/wikipedia.py`
|
|
||||||
- Create: `tests/test_wikipedia.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests for `wiki_summary`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_wikipedia.py
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, patch, MagicMock
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_summary_returns_extract():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_summary
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"type": "standard",
|
|
||||||
"title": "Python (programming language)",
|
|
||||||
"extract": "Python is a high-level programming language.",
|
|
||||||
"content_urls": {
|
|
||||||
"desktop": {"page": "https://en.wikipedia.org/wiki/Python_(programming_language)"}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(return_value=mock_response)
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
result = await wiki_summary("Python programming language")
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result["title"] == "Python (programming language)"
|
|
||||||
assert "high-level" in result["extract"]
|
|
||||||
assert "wikipedia.org" in result["url"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_summary_returns_none_on_404():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_summary
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(side_effect=httpx.HTTPStatusError(
|
|
||||||
"Not Found", request=MagicMock(), response=MagicMock(status_code=404)
|
|
||||||
))
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
result = await wiki_summary("xyznonexistenttopic123")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_summary_returns_none_on_disambiguation():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_summary
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"type": "disambiguation",
|
|
||||||
"title": "Python",
|
|
||||||
"extract": "Python may refer to...",
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(return_value=mock_response)
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
result = await wiki_summary("Python")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
|
||||||
Expected: FAIL with `ModuleNotFoundError: No module named 'fabledassistant.services.wikipedia'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `wiki_summary`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# src/fabledassistant/services/wikipedia.py
|
|
||||||
"""Wikipedia API: lightweight topic lookups and article search."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from urllib.parse import quote as url_quote
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary"
|
|
||||||
_SEARCH_URL = "https://en.wikipedia.org/w/api.php"
|
|
||||||
_TIMEOUT = 5.0
|
|
||||||
_USER_AGENT = "FabledAssistant/1.0 (https://fabledsword.com)"
|
|
||||||
|
|
||||||
|
|
||||||
async def wiki_summary(query: str) -> dict | None:
|
|
||||||
"""Look up a topic by title via the Wikipedia REST summary endpoint.
|
|
||||||
|
|
||||||
Returns {"title", "extract", "url"} on hit, None on miss.
|
|
||||||
"""
|
|
||||||
encoded = url_quote(query.replace(" ", "_"), safe="")
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
|
|
||||||
) as client:
|
|
||||||
resp = await client.get(f"{_SUMMARY_URL}/{encoded}", follow_redirects=True)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Wikipedia summary lookup failed for %r", query, exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if data.get("type") == "disambiguation":
|
|
||||||
return None
|
|
||||||
|
|
||||||
extract = data.get("extract", "").strip()
|
|
||||||
if not extract:
|
|
||||||
return None
|
|
||||||
|
|
||||||
url = (
|
|
||||||
data.get("content_urls", {}).get("desktop", {}).get("page")
|
|
||||||
or f"https://en.wikipedia.org/wiki/{encoded}"
|
|
||||||
)
|
|
||||||
return {"title": data.get("title", query), "extract": extract, "url": url}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
|
||||||
Expected: 3 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Write failing tests for `wiki_search`**
|
|
||||||
|
|
||||||
Add to `tests/test_wikipedia.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_search_returns_results():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_search
|
|
||||||
|
|
||||||
search_response = MagicMock()
|
|
||||||
search_response.status_code = 200
|
|
||||||
search_response.json.return_value = {
|
|
||||||
"query": {
|
|
||||||
"search": [
|
|
||||||
{"title": "QUIC"},
|
|
||||||
{"title": "HTTP/3"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
search_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
summary_response = MagicMock()
|
|
||||||
summary_response.status_code = 200
|
|
||||||
summary_response.json.return_value = {
|
|
||||||
"type": "standard",
|
|
||||||
"title": "QUIC",
|
|
||||||
"extract": "QUIC is a transport layer protocol.",
|
|
||||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/QUIC"}},
|
|
||||||
}
|
|
||||||
summary_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(side_effect=[search_response, summary_response, summary_response])
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
results = await wiki_search("QUIC protocol", limit=2)
|
|
||||||
|
|
||||||
assert len(results) >= 1
|
|
||||||
assert results[0]["title"] == "QUIC"
|
|
||||||
assert "transport" in results[0]["extract"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_search_returns_empty_on_failure():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_search
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection failed"))
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
results = await wiki_search("anything")
|
|
||||||
|
|
||||||
assert results == []
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Implement `wiki_search`**
|
|
||||||
|
|
||||||
Add to `src/fabledassistant/services/wikipedia.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def wiki_search(query: str, limit: int = 3) -> list[dict]:
|
|
||||||
"""Search Wikipedia for articles matching a query.
|
|
||||||
|
|
||||||
Returns [{"title", "extract", "url"}, ...] (may be empty).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
|
|
||||||
) as client:
|
|
||||||
resp = await client.get(_SEARCH_URL, params={
|
|
||||||
"action": "query",
|
|
||||||
"list": "search",
|
|
||||||
"srsearch": query,
|
|
||||||
"srlimit": str(limit),
|
|
||||||
"format": "json",
|
|
||||||
})
|
|
||||||
resp.raise_for_status()
|
|
||||||
hits = resp.json().get("query", {}).get("search", [])
|
|
||||||
if not hits:
|
|
||||||
return []
|
|
||||||
|
|
||||||
results: list[dict] = []
|
|
||||||
for hit in hits:
|
|
||||||
title = hit.get("title", "")
|
|
||||||
if not title:
|
|
||||||
continue
|
|
||||||
encoded = url_quote(title.replace(" ", "_"), safe="")
|
|
||||||
try:
|
|
||||||
summary_resp = await client.get(
|
|
||||||
f"{_SUMMARY_URL}/{encoded}", follow_redirects=True,
|
|
||||||
)
|
|
||||||
summary_resp.raise_for_status()
|
|
||||||
data = summary_resp.json()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if data.get("type") == "disambiguation":
|
|
||||||
continue
|
|
||||||
extract = data.get("extract", "").strip()
|
|
||||||
if not extract:
|
|
||||||
continue
|
|
||||||
url = (
|
|
||||||
data.get("content_urls", {}).get("desktop", {}).get("page")
|
|
||||||
or f"https://en.wikipedia.org/wiki/{encoded}"
|
|
||||||
)
|
|
||||||
results.append({"title": data.get("title", title), "extract": extract, "url": url})
|
|
||||||
return results
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Wikipedia search failed for %r", query, exc_info=True)
|
|
||||||
return []
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run all wikipedia tests**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
|
||||||
Expected: 5 passed
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/wikipedia.py tests/test_wikipedia.py
|
|
||||||
git commit -m "feat: add wikipedia service with summary lookup and search"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Lookup Tool (replaces search_web)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/tools/web.py`
|
|
||||||
- Create: `tests/test_lookup_tool.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests for `lookup`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_lookup_tool.py
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, patch, MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lookup_wikipedia_hit():
|
|
||||||
"""lookup returns wikipedia source when wiki_summary succeeds."""
|
|
||||||
wiki_data = {
|
|
||||||
"title": "QUIC",
|
|
||||||
"extract": "QUIC is a transport layer protocol.",
|
|
||||||
"url": "https://en.wikipedia.org/wiki/QUIC",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=wiki_data):
|
|
||||||
from fabledassistant.services.tools.web import lookup_tool
|
|
||||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
|
||||||
|
|
||||||
assert result["success"] is True
|
|
||||||
assert result["type"] == "lookup"
|
|
||||||
assert result["source"] == "wikipedia"
|
|
||||||
assert result["data"]["title"] == "QUIC"
|
|
||||||
assert "transport" in result["data"]["extract"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lookup_wikipedia_miss_searxng_fallback():
|
|
||||||
"""lookup falls back to SearXNG + article fetch when Wikipedia misses."""
|
|
||||||
searxng_results = [
|
|
||||||
{"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."},
|
|
||||||
]
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
|
||||||
patch("fabledassistant.services.tools.web.Config") as mock_config, \
|
|
||||||
patch("fabledassistant.services.tools.web._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
|
||||||
patch("fabledassistant.services.tools.web._fetch_full_article", new_callable=AsyncMock, return_value="Full article about QUIC..."):
|
|
||||||
mock_config.searxng_enabled.return_value = True
|
|
||||||
from fabledassistant.services.tools.web import lookup_tool
|
|
||||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
|
||||||
|
|
||||||
assert result["success"] is True
|
|
||||||
assert result["type"] == "lookup"
|
|
||||||
assert result["source"] == "web"
|
|
||||||
assert result["data"]["results"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lookup_wikipedia_miss_no_searxng():
|
|
||||||
"""lookup returns no-results when Wikipedia misses and SearXNG is not configured."""
|
|
||||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
|
||||||
patch("fabledassistant.services.tools.web.Config") as mock_config:
|
|
||||||
mock_config.searxng_enabled.return_value = False
|
|
||||||
from fabledassistant.services.tools.web import lookup_tool
|
|
||||||
result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"})
|
|
||||||
|
|
||||||
assert result["success"] is True
|
|
||||||
assert result["source"] == "none"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lookup_always_available():
|
|
||||||
"""lookup tool must appear in get_tools_for_user regardless of SearXNG config."""
|
|
||||||
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \
|
|
||||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"):
|
|
||||||
from fabledassistant.services.tools import get_tools_for_user
|
|
||||||
tools = await get_tools_for_user(user_id=1)
|
|
||||||
tool_names = {t["function"]["name"] for t in tools}
|
|
||||||
assert "lookup" in tool_names
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_lookup_tool.py -v`
|
|
||||||
Expected: FAIL (no `lookup_tool` function)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Replace `search_web` with `lookup` in `web.py`**
|
|
||||||
|
|
||||||
Replace the `search_web_tool` function (lines 12–36 of `src/fabledassistant/services/tools/web.py`) with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@tool(
|
|
||||||
name="lookup",
|
|
||||||
description=(
|
|
||||||
"Look up a topic, concept, or factual question. Returns a concise answer from "
|
|
||||||
"Wikipedia or web sources. Use for definitions, explanations, 'what is X', "
|
|
||||||
"'how does Y work', current events, or version numbers. No note is saved. "
|
|
||||||
"For comprehensive written reports saved as notes, use research_topic instead."
|
|
||||||
),
|
|
||||||
parameters={
|
|
||||||
"query": {"type": "string", "description": "The topic or question to look up"},
|
|
||||||
},
|
|
||||||
required=["query"],
|
|
||||||
)
|
|
||||||
async def lookup_tool(*, user_id, arguments, **_ctx):
|
|
||||||
from fabledassistant.config import Config
|
|
||||||
from fabledassistant.services.wikipedia import wiki_summary
|
|
||||||
|
|
||||||
query = arguments.get("query", "")
|
|
||||||
|
|
||||||
# 1. Try Wikipedia first
|
|
||||||
wiki = await wiki_summary(query)
|
|
||||||
if wiki:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"type": "lookup",
|
|
||||||
"source": "wikipedia",
|
|
||||||
"data": wiki,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Fall back to SearXNG + article fetch
|
|
||||||
if Config.searxng_enabled():
|
|
||||||
from fabledassistant.services.research import _search_searxng
|
|
||||||
from fabledassistant.services.rss import _fetch_full_article
|
|
||||||
|
|
||||||
results = await _search_searxng(query)
|
|
||||||
if results:
|
|
||||||
articles: list[dict] = []
|
|
||||||
for r in results[:2]:
|
|
||||||
url = r.get("url", "")
|
|
||||||
if not url:
|
|
||||||
continue
|
|
||||||
content = await _fetch_full_article(url)
|
|
||||||
articles.append({
|
|
||||||
"url": url,
|
|
||||||
"title": r.get("title", url),
|
|
||||||
"snippet": r.get("snippet", ""),
|
|
||||||
"content": (content or "")[:4000],
|
|
||||||
})
|
|
||||||
if articles:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"type": "lookup",
|
|
||||||
"source": "web",
|
|
||||||
"data": {"query": query, "results": articles},
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. No sources available
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"type": "lookup",
|
|
||||||
"source": "none",
|
|
||||||
"data": {
|
|
||||||
"query": query,
|
|
||||||
"message": "No results found. You can answer from your own knowledge.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run lookup tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_lookup_tool.py -v`
|
|
||||||
Expected: 4 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/tools/web.py tests/test_lookup_tool.py
|
|
||||||
git commit -m "feat: replace search_web with unified lookup tool (Wikipedia + SearXNG fallback)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Update All `search_web` References
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/tools/web.py:45` (research_topic description)
|
|
||||||
- Modify: `src/fabledassistant/services/tools/web.py:67` (search_images description)
|
|
||||||
- Modify: `src/fabledassistant/services/tools/rss.py:75` (read_article description)
|
|
||||||
- Modify: `src/fabledassistant/services/generation_task.py:133` (status label map)
|
|
||||||
- Modify: `src/fabledassistant/services/llm.py:608` (action list)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update `research_topic` description**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/tools/web.py`, change the `research_topic` description from:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"For a quick factual answer without saving a note, use search_web."
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"For a quick factual answer without saving a note, use lookup."
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update `search_images` description**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/tools/web.py`, change the `search_images` description from:
|
|
||||||
|
|
||||||
```python
|
|
||||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use search_web for those.",
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use lookup for those.",
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update `read_article` description**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/tools/rss.py`, change:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"Do NOT use search_web for URLs — use this tool instead."
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"Do NOT use lookup for URLs — use this tool instead."
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update status label map in `generation_task.py`**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/generation_task.py`, line 133, change:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"search_web": "Searching the web",
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"lookup": "Looking up information",
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update action list in `llm.py`**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/llm.py`, line 608, change:
|
|
||||||
|
|
||||||
```python
|
|
||||||
actions.extend(["search_web", "research_topic", "search_images"])
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
actions.extend(["lookup", "research_topic", "search_images"])
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run full test suite to check for regressions**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/ -v`
|
|
||||||
Expected: All tests pass (no test references `search_web` by name in assertions)
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/tools/web.py src/fabledassistant/services/tools/rss.py src/fabledassistant/services/generation_task.py src/fabledassistant/services/llm.py
|
|
||||||
git commit -m "refactor: update all search_web references to lookup"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Add Wikipedia Sources to Research Pipeline
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/research.py`
|
|
||||||
- Modify: `tests/test_research_pipeline.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test for Wikipedia in research pipeline**
|
|
||||||
|
|
||||||
Add to `tests/test_research_pipeline.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pipeline_includes_wikipedia_sources():
|
|
||||||
"""run_research_pipeline should merge Wikipedia results into the source pool."""
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}]
|
|
||||||
|
|
||||||
outline = [
|
|
||||||
{"title": "Section A", "focus": "Focus A"},
|
|
||||||
{"title": "Section B", "focus": "Focus B"},
|
|
||||||
]
|
|
||||||
|
|
||||||
note_id_counter = iter(range(30, 40))
|
|
||||||
|
|
||||||
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
|
|
||||||
n = MagicMock()
|
|
||||||
n.id = next(note_id_counter)
|
|
||||||
n.title = title
|
|
||||||
return n
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
|
||||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
|
||||||
patch("fabledassistant.services.research.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \
|
|
||||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
|
||||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline) as mock_outline, \
|
|
||||||
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
|
|
||||||
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \
|
|
||||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
|
|
||||||
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock):
|
|
||||||
|
|
||||||
from fabledassistant.services.research import run_research_pipeline
|
|
||||||
await run_research_pipeline("test topic", user_id=1, model="test-model")
|
|
||||||
|
|
||||||
# The sources passed to _generate_outline should include the Wikipedia article
|
|
||||||
sources_arg = mock_outline.call_args[0][1] # second positional arg
|
|
||||||
source_urls = [s["url"] for s in sources_arg]
|
|
||||||
assert "https://en.wikipedia.org/wiki/Topic" in source_urls
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
|
|
||||||
Expected: FAIL (no `wiki_search` import in research.py)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add Wikipedia sources to the research pipeline**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/research.py`, add the import at the top (after existing imports):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fabledassistant.services.wikipedia import wiki_search
|
|
||||||
```
|
|
||||||
|
|
||||||
Then modify Step 2 (the parallel search section, around lines 208–246). Replace:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Step 2: Search all queries in parallel (200 ms stagger to avoid hammering SearXNG)
|
|
||||||
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
|
||||||
if i > 0:
|
|
||||||
await asyncio.sleep(0.2 * i)
|
|
||||||
_status(f"Searching: {query}...")
|
|
||||||
results = await _search_searxng(query)
|
|
||||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
|
||||||
return query, results
|
|
||||||
|
|
||||||
search_results = await asyncio.gather(
|
|
||||||
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Deduplicate URLs across all queries
|
|
||||||
seen_urls: set[str] = set()
|
|
||||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
|
||||||
for query, results in search_results:
|
|
||||||
for result in results[:PAGES_PER_QUERY]:
|
|
||||||
url = result.get("url", "")
|
|
||||||
if url and url not in seen_urls:
|
|
||||||
seen_urls.add(url)
|
|
||||||
url_tasks.append((url, result, query))
|
|
||||||
|
|
||||||
# Fetch all unique URLs in parallel
|
|
||||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
|
||||||
title = result.get("title", url)
|
|
||||||
_status(f"Reading: {title[:60]}...")
|
|
||||||
content = await fetch_url_content(url)
|
|
||||||
return {
|
|
||||||
"url": url,
|
|
||||||
"title": title,
|
|
||||||
"query": query,
|
|
||||||
"snippet": result.get("snippet", ""),
|
|
||||||
"content": content,
|
|
||||||
}
|
|
||||||
|
|
||||||
all_sources: list[dict] = list(await asyncio.gather(
|
|
||||||
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
|
||||||
))
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Step 2: Search all queries in parallel (SearXNG + Wikipedia)
|
|
||||||
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
|
||||||
if i > 0:
|
|
||||||
await asyncio.sleep(0.2 * i)
|
|
||||||
_status(f"Searching: {query}...")
|
|
||||||
results = await _search_searxng(query)
|
|
||||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
|
||||||
return query, results
|
|
||||||
|
|
||||||
async def _wiki_for_query(query: str) -> list[dict]:
|
|
||||||
return await wiki_search(query, limit=1)
|
|
||||||
|
|
||||||
searxng_task = asyncio.gather(
|
|
||||||
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
|
|
||||||
)
|
|
||||||
wiki_task = asyncio.gather(
|
|
||||||
*[_wiki_for_query(q) for q in queries]
|
|
||||||
)
|
|
||||||
search_results, wiki_results = await asyncio.gather(searxng_task, wiki_task)
|
|
||||||
|
|
||||||
# Deduplicate URLs across all queries
|
|
||||||
seen_urls: set[str] = set()
|
|
||||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
|
||||||
wiki_sources: list[dict] = [] # Wikipedia articles (already have content)
|
|
||||||
|
|
||||||
for query, results in search_results:
|
|
||||||
for result in results[:PAGES_PER_QUERY]:
|
|
||||||
url = result.get("url", "")
|
|
||||||
if url and url not in seen_urls:
|
|
||||||
seen_urls.add(url)
|
|
||||||
url_tasks.append((url, result, query))
|
|
||||||
|
|
||||||
# Add Wikipedia results (they already have content via extract)
|
|
||||||
for query, wiki_hits in zip(queries, wiki_results):
|
|
||||||
for hit in wiki_hits:
|
|
||||||
url = hit.get("url", "")
|
|
||||||
if url and url not in seen_urls:
|
|
||||||
seen_urls.add(url)
|
|
||||||
wiki_sources.append({
|
|
||||||
"url": url,
|
|
||||||
"title": hit["title"],
|
|
||||||
"query": query,
|
|
||||||
"snippet": hit["extract"][:200],
|
|
||||||
"content": hit["extract"],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Fetch all unique SearXNG URLs in parallel
|
|
||||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
|
||||||
title = result.get("title", url)
|
|
||||||
_status(f"Reading: {title[:60]}...")
|
|
||||||
content = await fetch_url_content(url)
|
|
||||||
return {
|
|
||||||
"url": url,
|
|
||||||
"title": title,
|
|
||||||
"query": query,
|
|
||||||
"snippet": result.get("snippet", ""),
|
|
||||||
"content": content,
|
|
||||||
}
|
|
||||||
|
|
||||||
fetched_sources: list[dict] = list(await asyncio.gather(
|
|
||||||
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
|
||||||
))
|
|
||||||
|
|
||||||
all_sources = wiki_sources + fetched_sources
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the new test to verify it passes**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run the full research test suite for regressions**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_research_pipeline.py -v`
|
|
||||||
Expected: All tests pass
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run full test suite**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/ -v`
|
|
||||||
Expected: All tests pass
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/research.py tests/test_research_pipeline.py
|
|
||||||
git commit -m "feat: add Wikipedia as research pipeline source alongside SearXNG"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Lint, Typecheck, Final Verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- All modified files
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run ruff lint**
|
|
||||||
|
|
||||||
Run: `uv run ruff check src/fabledassistant/services/wikipedia.py src/fabledassistant/services/tools/web.py src/fabledassistant/services/research.py tests/test_wikipedia.py tests/test_lookup_tool.py`
|
|
||||||
Expected: All checks passed (fix any issues if not)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run typecheck**
|
|
||||||
|
|
||||||
Run: `cd frontend && npx vue-tsc --noEmit` (frontend unchanged, but verify nothing broke)
|
|
||||||
Expected: Clean
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run full test suite one last time**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/ -v`
|
|
||||||
Expected: All tests pass
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit any lint fixes if needed**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -u
|
|
||||||
git commit -m "fix: lint cleanup for lookup/wikipedia changes"
|
|
||||||
```
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
# 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"`
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
# Specialized Note Type Editors — Design Spec
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the one-size-fits-all note editor with type-specialized views for Person, Place, and List. Each type gets a form-first layout where structured fields are the main content, with a secondary notes area for free text. Fix tab navigation across all note types so focus flows logically from title through fields to body, skipping the formatting toolbar.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The existing `NoteEditorView.vue` remains the single editor component but renders different layouts based on `noteType`. When `noteType` is `person`, `place`, or `list`, the main editor area switches from TipTap-first to form-first. The TipTap editor moves to a secondary "Notes" section below the form fields. The sidebar metadata fields for person/place move into the main content area. The `note_type` field, entity metadata storage, and API contract are unchanged.
|
|
||||||
|
|
||||||
## Person Editor
|
|
||||||
|
|
||||||
When `noteType === 'person'`, the main content area renders a contact card form instead of the TipTap editor.
|
|
||||||
|
|
||||||
### Fields (in order, all in main content area)
|
|
||||||
|
|
||||||
| Field | Type | Placeholder | Source |
|
|
||||||
|-------|------|-------------|--------|
|
|
||||||
| Name | text input (title) | "Name" | `title` |
|
|
||||||
| Relationship | text input | "e.g. Friend, Colleague, Family" | `entityMeta.relationship` |
|
|
||||||
| Birthday | date input | — | `entityMeta.birthday` (new field) |
|
|
||||||
| Email | email input | "email@example.com" | `entityMeta.email` |
|
|
||||||
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
|
|
||||||
| Organization | text input | "Company or organization" | `entityMeta.organization` (new field) |
|
|
||||||
| Address | text input | "Street, City, State" | `entityMeta.address` (new field for person) |
|
|
||||||
|
|
||||||
### Notes section
|
|
||||||
|
|
||||||
Below the form fields, a collapsible "Notes" section with the TipTap editor for free-text content. This is where wikilinks, tags, and general context go. The section starts expanded if the note already has body content, collapsed if empty on a new note.
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ [← Knowledge] [Save] [Delete] │
|
|
||||||
│ │
|
|
||||||
│ Name: [________________________________] │
|
|
||||||
│ │
|
|
||||||
│ Relationship: [________________________] │
|
|
||||||
│ Birthday: [____date picker________] │
|
|
||||||
│ Email: [________________________] │
|
|
||||||
│ Phone: [________________________] │
|
|
||||||
│ Organization: [________________________] │
|
|
||||||
│ Address: [________________________] │
|
|
||||||
│ │
|
|
||||||
│ ▾ Notes │
|
|
||||||
│ ┌──────────────────────────────────────┐ │
|
|
||||||
│ │ TipTap editor (markdown body) │ │
|
|
||||||
│ └──────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [sidebar: project/tags/etc] │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data migration
|
|
||||||
|
|
||||||
Existing person notes may have structured data written as plain text in the body (e.g. "Relationship: daughter Birthday: 2013-12-13"). No automatic migration — the body content stays as-is in the Notes section. Users can move data to the structured fields manually.
|
|
||||||
|
|
||||||
## Place Editor
|
|
||||||
|
|
||||||
When `noteType === 'place'`, same form-first pattern.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
| Field | Type | Placeholder | Source |
|
|
||||||
|-------|------|-------------|--------|
|
|
||||||
| Name | text input (title) | "Place name" | `title` |
|
|
||||||
| Address | text input | "Street, City, State" | `entityMeta.address` |
|
|
||||||
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
|
|
||||||
| Hours | text input | "e.g. Mon–Fri 9am–5pm" | `entityMeta.hours` |
|
|
||||||
| Website | url input | "https://..." | `entityMeta.website` (new field) |
|
|
||||||
| Category | text input | "e.g. Restaurant, Office, Doctor" | `entityMeta.category` (new field) |
|
|
||||||
|
|
||||||
### Notes section
|
|
||||||
|
|
||||||
Same as Person — collapsible TipTap editor below the form.
|
|
||||||
|
|
||||||
## List Editor
|
|
||||||
|
|
||||||
When `noteType === 'list'`, the main content area renders a checklist builder instead of the TipTap editor.
|
|
||||||
|
|
||||||
### List builder
|
|
||||||
|
|
||||||
Each list item is a row with:
|
|
||||||
- Checkbox (toggle checked state)
|
|
||||||
- Text input (item text, fills available width)
|
|
||||||
- Delete button (× icon, right side)
|
|
||||||
|
|
||||||
Below the items: an "Add item" button.
|
|
||||||
|
|
||||||
### Behavior
|
|
||||||
|
|
||||||
- **Enter** in any item input: creates a new item below and focuses it
|
|
||||||
- **Backspace** on an empty item: deletes the item and focuses the previous one
|
|
||||||
- **Checkbox toggle**: updates the item's checked state
|
|
||||||
- **Delete button**: removes the item
|
|
||||||
|
|
||||||
### Serialization
|
|
||||||
|
|
||||||
On save, list items are serialized to markdown checkbox format in the body:
|
|
||||||
```markdown
|
|
||||||
- [ ] Buy groceries
|
|
||||||
- [x] Call dentist
|
|
||||||
- [ ] Pick up prescription
|
|
||||||
```
|
|
||||||
|
|
||||||
On load, the body is parsed back into structured items (same parser already exists in `knowledge.py` and `KnowledgeView.vue`).
|
|
||||||
|
|
||||||
### Notes section
|
|
||||||
|
|
||||||
Same collapsible TipTap "Notes" section below the list builder, for additional context that isn't a list item.
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ [← Knowledge] [Save] [Delete] │
|
|
||||||
│ │
|
|
||||||
│ List title: [____________________________│
|
|
||||||
│ │
|
|
||||||
│ [ ] Buy groceries [×] │
|
|
||||||
│ [x] Call dentist [×] │
|
|
||||||
│ [ ] Pick up prescription [×] │
|
|
||||||
│ │
|
|
||||||
│ [+ Add item] │
|
|
||||||
│ │
|
|
||||||
│ ▾ Notes │
|
|
||||||
│ ┌──────────────────────────────────────┐ │
|
|
||||||
│ │ TipTap editor (additional context) │ │
|
|
||||||
│ └──────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [sidebar: project/tags/etc] │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tab Navigation & Auto-Focus
|
|
||||||
|
|
||||||
### All note types
|
|
||||||
|
|
||||||
1. **On page load**: focus the title/name input automatically
|
|
||||||
2. **Tab from title**: skip the formatting toolbar entirely, go to the first content field:
|
|
||||||
- Note: TipTap editor body
|
|
||||||
- Person: Relationship field
|
|
||||||
- Place: Address field
|
|
||||||
- List: first list item (or "Add item" button if empty)
|
|
||||||
3. **Tab through fields**: natural order through all form fields
|
|
||||||
4. **Tab from last form field**: enter the Notes section (TipTap editor)
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
Set `tabindex="-1"` on all MarkdownToolbar buttons so they are clickable but not in the tab order. The toolbar remains fully functional via mouse/touch — it's just skipped when tabbing.
|
|
||||||
|
|
||||||
### Title placeholder by type
|
|
||||||
|
|
||||||
| Type | Placeholder |
|
|
||||||
|------|-------------|
|
|
||||||
| Note | "Title" |
|
|
||||||
| Person | "Name" |
|
|
||||||
| Place | "Place name" |
|
|
||||||
| List | "List title" |
|
|
||||||
| Task | "Title" (unchanged, task editor is separate) |
|
|
||||||
|
|
||||||
## Sidebar changes
|
|
||||||
|
|
||||||
When editing a Person or Place, the type-specific metadata fields (Relationship, Email, Phone, etc.) **move from the sidebar to the main content area**. The sidebar keeps: Project, Milestone, Tags, Suggest Tags, Type selector, Link Suggestions, Writing Assistant, Version History.
|
|
||||||
|
|
||||||
The Type selector remains in the sidebar so users can change the type if needed. Changing type switches the layout.
|
|
||||||
|
|
||||||
## Backend changes
|
|
||||||
|
|
||||||
### New entity metadata fields
|
|
||||||
|
|
||||||
The `entity_meta` JSON column on the Note model already stores arbitrary key-value pairs. No schema migration needed — just store the new keys:
|
|
||||||
|
|
||||||
- Person: `birthday`, `organization`, `address` (new; `relationship`, `email`, `phone` existing)
|
|
||||||
- Place: `website`, `category` (new; `address`, `phone`, `hours` existing)
|
|
||||||
|
|
||||||
### Knowledge service
|
|
||||||
|
|
||||||
Update `_note_to_item` in `services/knowledge.py` to include the new fields in the response for person and place cards:
|
|
||||||
|
|
||||||
- Person: add `birthday`, `organization`, `address`
|
|
||||||
- Place: add `website`, `category`
|
|
||||||
|
|
||||||
### Knowledge card display
|
|
||||||
|
|
||||||
Update `KnowledgeView.vue` card rendering to show the new fields where useful (e.g. organization on person cards, category on place cards).
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `frontend/src/views/NoteEditorView.vue` | Type-conditional layouts, form fields, list builder, tab navigation, auto-focus, title placeholders |
|
|
||||||
| `frontend/src/views/KnowledgeView.vue` | Card display for new person/place fields |
|
|
||||||
| `frontend/src/components/MarkdownToolbar.vue` | `tabindex="-1"` on all buttons |
|
|
||||||
| `src/fabledassistant/services/knowledge.py` | New fields in `_note_to_item` for person/place |
|
|
||||||
|
|
||||||
## What does NOT change
|
|
||||||
|
|
||||||
- Note model / database schema (entity_meta is already a JSON column)
|
|
||||||
- API endpoints (same CRUD)
|
|
||||||
- Task editor (`TaskEditorView.vue`) — separate component, unchanged
|
|
||||||
- Generic note editing — TipTap-first layout stays for `noteType === 'note'`
|
|
||||||
- Backend storage format — entity_meta key-value pairs
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# Unified Lookup Tool & Wikipedia Integration
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the fragmented `search_web` tool with a single `lookup` tool that checks Wikipedia first and falls back to SearXNG web search. Add Wikipedia as an additional source in the research pipeline. Result: one lightweight tool for factual questions (always available, no config required), and richer research output.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Two changes to the search/knowledge stack:
|
|
||||||
|
|
||||||
1. **New `lookup` tool** replaces `search_web`. Tries Wikipedia REST API summary endpoint first (~200ms, reliable, no config). Falls back to SearXNG + trafilatura article fetch when Wikipedia misses and SearXNG is configured. Always available (no `requires` field).
|
|
||||||
|
|
||||||
2. **Wikipedia sources in research pipeline.** During sub-query execution, `wiki_search` runs alongside `_search_searxng`. Wikipedia articles merge into the source pool and get deduplicated by URL.
|
|
||||||
|
|
||||||
Shared Wikipedia logic lives in a new `wikipedia.py` service module.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### `src/fabledassistant/services/wikipedia.py` (new)
|
|
||||||
|
|
||||||
Two async functions:
|
|
||||||
|
|
||||||
**`wiki_summary(query: str) -> dict | None`**
|
|
||||||
- Direct title lookup via `https://en.wikipedia.org/api/rest_v1/page/summary/{title}`
|
|
||||||
- Returns `{"title": str, "extract": str, "url": str}` on hit
|
|
||||||
- Returns `None` on 404, disambiguation pages (`"type": "disambiguation"`), network errors, or empty extracts
|
|
||||||
- 5-second timeout
|
|
||||||
- User-Agent: `"FabledAssistant/1.0 (https://fabledsword.com)"`
|
|
||||||
|
|
||||||
**`wiki_search(query: str, limit: int = 3) -> list[dict]`**
|
|
||||||
- Search via `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={query}&srlimit={limit}&format=json`
|
|
||||||
- For each search result, fetch its summary via the summary endpoint to get the extract
|
|
||||||
- Returns `[{"title": str, "extract": str, "url": str}, ...]`
|
|
||||||
- Returns `[]` on any failure
|
|
||||||
- Same timeout and User-Agent as above
|
|
||||||
|
|
||||||
### `src/fabledassistant/services/tools/web.py` (modified)
|
|
||||||
|
|
||||||
**Remove:** `search_web_tool`
|
|
||||||
|
|
||||||
**Add:** `lookup_tool`
|
|
||||||
|
|
||||||
```
|
|
||||||
@tool(
|
|
||||||
name="lookup",
|
|
||||||
description="Look up a topic, concept, or factual question. Returns a concise
|
|
||||||
answer from Wikipedia or web sources. Use for definitions,
|
|
||||||
explanations, 'what is X', 'how does Y work'. For comprehensive
|
|
||||||
written reports saved as notes, use research_topic instead.",
|
|
||||||
parameters={
|
|
||||||
"query": {"type": "string", "description": "The topic or question to look up"},
|
|
||||||
},
|
|
||||||
required=["query"],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
No `requires` field — always available.
|
|
||||||
|
|
||||||
**Logic:**
|
|
||||||
1. Call `wiki_summary(query)`
|
|
||||||
2. If Wikipedia returns a result: return `{"success": True, "type": "lookup", "source": "wikipedia", "data": {"title": ..., "extract": ..., "url": ...}}`
|
|
||||||
3. If Wikipedia misses and `Config.searxng_enabled()`:
|
|
||||||
- Call `_search_searxng(query)` to get search results
|
|
||||||
- Fetch top 1-2 result URLs via `_fetch_full_article` (from `rss.py`, trafilatura-based)
|
|
||||||
- Return `{"success": True, "type": "lookup", "source": "web", "data": {"query": ..., "results": [...], "content": ...}}`
|
|
||||||
4. If Wikipedia misses and no SearXNG: return `{"success": True, "type": "lookup", "source": "none", "data": {"query": ..., "message": "No results found. You can answer from your own knowledge."}}`
|
|
||||||
|
|
||||||
### `src/fabledassistant/services/research.py` (modified)
|
|
||||||
|
|
||||||
**In Step 2 (parallel search):**
|
|
||||||
- For each sub-query, run `wiki_search(query, limit=1)` concurrently with `_search_searxng(query)`
|
|
||||||
- Merge Wikipedia results into the per-query result list
|
|
||||||
|
|
||||||
**In Step 3 (deduplication):**
|
|
||||||
- When deduplicating URLs, Wikipedia URLs (`wikipedia.org`) are checked against SearXNG results
|
|
||||||
- If a Wikipedia article URL already appears in SearXNG results, skip the duplicate
|
|
||||||
|
|
||||||
**Wikipedia article content for synthesis:**
|
|
||||||
- The `extract` from `wiki_search` is used as the source content (no additional fetch needed, unlike SearXNG URLs which require `fetch_url_content`)
|
|
||||||
- This means Wikipedia sources are available immediately without an HTTP fetch step
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- All Wikipedia API failures (network, timeout, malformed JSON) return `None`/`[]` silently
|
|
||||||
- `lookup` never raises — always returns a response the model can work with
|
|
||||||
- In the research pipeline, Wikipedia is purely additive; its failure never degrades existing SearXNG-based research
|
|
||||||
- Disambiguation pages are detected via `"type": "disambiguation"` in the summary response and treated as a miss
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### `tests/test_wikipedia.py` (new)
|
|
||||||
|
|
||||||
- `test_wiki_summary_returns_extract` — mock successful summary response, verify return shape
|
|
||||||
- `test_wiki_summary_returns_none_on_404` — mock 404, verify `None`
|
|
||||||
- `test_wiki_summary_returns_none_on_disambiguation` — mock disambiguation response, verify `None`
|
|
||||||
- `test_wiki_search_returns_results` — mock search API + summary fetches, verify list
|
|
||||||
- `test_wiki_search_returns_empty_on_failure` — mock network error, verify `[]`
|
|
||||||
|
|
||||||
### `tests/test_lookup_tool.py` (new)
|
|
||||||
|
|
||||||
- `test_lookup_wikipedia_hit` — mock `wiki_summary` returning data, verify tool returns wikipedia source
|
|
||||||
- `test_lookup_wikipedia_miss_searxng_fallback` — mock `wiki_summary` returning None, SearXNG returning results + article fetch, verify web source
|
|
||||||
- `test_lookup_wikipedia_miss_no_searxng` — mock both missing, verify graceful "no results" response
|
|
||||||
- `test_lookup_always_available` — verify the tool appears in `get_tools_for_user` regardless of SearXNG config
|
|
||||||
|
|
||||||
### `tests/test_research_pipeline.py` (add to existing)
|
|
||||||
|
|
||||||
- `test_research_includes_wikipedia_sources` — mock `wiki_search` alongside SearXNG, verify Wikipedia results appear in source pool
|
|
||||||
|
|
||||||
All tests mock HTTP calls — no live API hits.
|
|
||||||
|
|
||||||
## What Doesn't Change
|
|
||||||
|
|
||||||
- `read_article` tool — stays as-is (explicit URL fetch, different purpose)
|
|
||||||
- `research_topic` tool definition — stays as-is (same name, description, parameters)
|
|
||||||
- `generation_task.py` research interception — stays as-is
|
|
||||||
- `search_images` tool — stays as-is
|
|
||||||
- `_search_searxng` and `_search_searxng_images` — stay as-is
|
|
||||||
- `_fetch_full_article` in `rss.py` — stays as-is, reused by `lookup` for SearXNG fallback
|
|
||||||
Reference in New Issue
Block a user