Compare commits
260 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3a056d6fd | |||
| 2f577fee58 | |||
| e5565b73dc | |||
| b579aa1c88 | |||
| e5796b6f5c | |||
| d8f577e753 | |||
| a40334312f | |||
| bfeed67cfe | |||
| 580e4a2c0a | |||
| c3af24ef51 | |||
| eb41e772cd | |||
| e7f214fc80 | |||
| f80c327ecf | |||
| ce47ebc7de | |||
| 7f0d99d383 | |||
| 84b75f7a73 | |||
| 30fbf7b117 | |||
| d04b6f4bba | |||
| 311322fdc8 | |||
| dc93675470 | |||
| 2f5ef9124a | |||
| b30cf06096 | |||
| 75d3d40038 | |||
| 1d5f49fe3b | |||
| b250141e15 | |||
| 4609abacd8 | |||
| e269ac9d5c | |||
| 737467f996 | |||
| fc4a1627b5 | |||
| 50d2a0e9c0 | |||
| 8754b1c94d | |||
| ac462d1203 | |||
| 5f3da7c004 | |||
| 9658e9a35c | |||
| f2afb2a8bf | |||
| 447adf816c | |||
| 75d8e7ab49 | |||
| 605dd0a13a | |||
| eab5c5a026 | |||
| a1a6c5e47e | |||
| bbbd6b2f28 | |||
| 0219c673c1 | |||
| 45fe198d54 | |||
| d3833ba5a4 | |||
| 38e4220015 | |||
| cfd801d181 | |||
| 45c2197cdf | |||
| 05e379263a | |||
| 4806c34a3c | |||
| b3fca3ced4 | |||
| 8d73f553ca | |||
| 42861142db | |||
| 91bafb641f | |||
| 8bec68abc0 | |||
| ba6f2c7614 | |||
| 18eb1e7ab2 | |||
| 05b0bf97d7 | |||
| 02fe500d61 | |||
| 06a532b0e6 | |||
| 65d3711a11 | |||
| 1fd303abe3 | |||
| 0cc09f917d | |||
| 6aa84002b3 | |||
| 97c22941a8 | |||
| 27b5c45f27 | |||
| 52d6a8ed53 | |||
| 12d0ebeb84 | |||
| 5a9859e12f | |||
| d4f3516552 | |||
| 6961144c3a | |||
| 9a76c4718b | |||
| 4d6bae77b4 | |||
| d086c9b606 | |||
| b026421985 | |||
| fd0431dfb6 | |||
| 3579db2f06 | |||
| 3cc5c7dcab | |||
| 38265906f1 | |||
| 94f7a6de37 | |||
| caa504913f | |||
| 198f11ee09 | |||
| b97a8ce457 | |||
| 8a8d6fc9f2 | |||
| eb02603092 | |||
| 1b65c44339 | |||
| f72bba91aa | |||
| 85b212fbf2 | |||
| 48b99b62be | |||
| 4048a771d2 | |||
| 3a316551be | |||
| 6be7328d8c | |||
| a988ffa349 | |||
| d76f52b578 | |||
| 37596ce31c | |||
| fdb0f10848 | |||
| 49325816a3 | |||
| dac5433353 | |||
| bccee7f192 | |||
| 2a374d9b86 | |||
| 9d70c7be76 | |||
| 7d71f126a2 | |||
| fa97ade8e3 | |||
| 83f1676d72 | |||
| a73dd17a1b | |||
| a7002a89a0 | |||
| 39ab5d69a9 | |||
| 4a9d8eaa2d | |||
| a28f75994a | |||
| c91b9c46ff | |||
| 9137bf698a | |||
| bf7a29e8a0 | |||
| d345b32856 | |||
| 2d5d3ffdff | |||
| cf986b5097 | |||
| d3d4294c30 | |||
| 41d252e9d1 | |||
| 6cf70e22db | |||
| 3f1bcc3360 | |||
| 5d2d27c499 | |||
| 2414437061 | |||
| e6f2ee2b94 | |||
| 59dee3a19f | |||
| ce41f2a3ee | |||
| b1226d4e16 | |||
| 37c704e875 | |||
| bb6249e00e | |||
| 9c0308dfee | |||
| 925a53e0f7 | |||
| b65d736869 | |||
| 17211c6e82 | |||
| 90aa1f2fdb | |||
| b519a1c140 | |||
| 257b306a27 | |||
| 8b0878f227 | |||
| 9191ab5b27 | |||
| fd25d2e436 | |||
| 103db883ad | |||
| 5fa203019a | |||
| bda6e6c80f | |||
| 5419330633 | |||
| 362ead7f0d | |||
| 8a3bba4eb8 | |||
| 76dc75a03b | |||
| a551f52682 | |||
| 6de855e226 | |||
| c6357e52d9 | |||
| 5d40f2113f | |||
| 9a96fdb3c0 | |||
| 460959f0d4 | |||
| 0dbbb98cf5 | |||
| 4b7ca1b17e | |||
| 65a3689aaa | |||
| 42c11dedae | |||
| 0fbb1fbd92 | |||
| bb650ba563 | |||
| c663532fd4 | |||
| 090b7d83dd | |||
| 4e9eead3ab | |||
| fc6ebf81eb | |||
| b88d5ee6b3 | |||
| 020bd6614b | |||
| 4403026797 | |||
| 552943d6c0 | |||
| 2576be9e49 | |||
| e17fc088b2 | |||
| c5b0344240 | |||
| c8765959ea | |||
| c33cab7020 | |||
| 36cd08c236 | |||
| f85b92a885 | |||
| 84640a0dc4 | |||
| 4c58603009 | |||
| b7e7073425 | |||
| 94b169f31c | |||
| 2db23cec7a | |||
| b81c4aa600 | |||
| 35fab6cbf7 | |||
| 404698521f | |||
| 9f8b451d15 | |||
| 4f18023284 | |||
| 6c309f1331 | |||
| 03d725ea3e | |||
| 611c940527 | |||
| 3b2a0a119f | |||
| a7de3296b3 | |||
| 88b351a96e | |||
| bc239119f3 | |||
| 30dfbce426 | |||
| 65ba1cc82a | |||
| 4cfea784a9 | |||
| 016a2bd270 | |||
| 39d56cfcf3 | |||
| 3f287d7417 | |||
| 64ab24864a | |||
| 3c1ec4077f | |||
| 541e2ed713 | |||
| ff498ce1a4 | |||
| efb3534f3a | |||
| 4192a64c0f | |||
| f20e54a068 | |||
| de4b1d7c7e | |||
| 3d916d7357 | |||
| df423ab906 | |||
| d023209f13 | |||
| 93a3beb5d6 | |||
| 54710b35e9 | |||
| 7a9a8b7819 | |||
| 057b30b89e | |||
| a3071a53cb | |||
| 4faaa5246b | |||
| 0ed9cbf666 | |||
| 4668c0950b | |||
| b728acd841 | |||
| c5498273c3 | |||
| 590a07bc13 | |||
| c9f2134ad4 | |||
| dda62265c9 | |||
| 297f2252a3 | |||
| dbd9f00061 | |||
| cacfcac86a | |||
| 873e70c7ea | |||
| 0c91ab026d | |||
| ce76a003f7 | |||
| ac188c40a5 | |||
| d9ab538ef4 | |||
| 7602bf2293 | |||
| d352e9264b | |||
| 6752765e2b | |||
| 0fe7141949 | |||
| b20d6dec66 | |||
| e652dece9b | |||
| 96a44886ef | |||
| f422d4158c | |||
| 70ec060b7a | |||
| 3b41a45757 | |||
| 19e1ca505d | |||
| f75c40b417 | |||
| 649c0f124b | |||
| 6026c24450 | |||
| ae5c61a179 | |||
| 7657f48f1e | |||
| 61e62a6904 | |||
| 9a252c8dde | |||
| 8db6b4d230 | |||
| d5e6a8f6da | |||
| 06cd3493fd | |||
| 6a8f0e9143 | |||
| 6f8c2201ed | |||
| ec49e24c8d | |||
| cac62c6caf | |||
| 619f069358 | |||
| ddab0db781 | |||
| 443b11f287 | |||
| 3c9e473823 | |||
| 3ac32dc3bc | |||
| 29ef17f4f3 | |||
| fc82f7a0cb | |||
| b743754ec2 | |||
| c4ffe418b5 | |||
| 6cf880506d |
+35
-12
@@ -42,6 +42,11 @@ on:
|
||||
- "assets/**"
|
||||
- "fable-mcp/**"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
# Manual trigger from the Forgejo Actions UI. Useful when an image has
|
||||
# been built but the deployment didn't pick it up, or when re-running
|
||||
# against the same source produces different upstream behaviour
|
||||
# (e.g. a transient HF download flake during the voice-bundle step).
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Cancel older runs on the same branch when a newer push lands. Tag runs
|
||||
# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and
|
||||
@@ -57,14 +62,16 @@ permissions:
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
# Skip on main merge-commit pushes — see workflow header comment.
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -86,11 +93,13 @@ jobs:
|
||||
lint:
|
||||
name: Python lint
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# ruff is pre-installed in the ci-runner base image — no install
|
||||
# ruff is pre-installed in the ci-python image — no install
|
||||
# step needed, lint runs in ~2s.
|
||||
- name: Lint
|
||||
run: ruff check src/
|
||||
@@ -98,7 +107,9 @@ jobs:
|
||||
test:
|
||||
name: Python tests
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -132,7 +143,9 @@ jobs:
|
||||
# pushes skip here too, so no production image is ever built from a
|
||||
# raw main push (only from the v* tag the release creates).
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -141,15 +154,25 @@ jobs:
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
# POSIX `case` instead of bash `[[ ]]` because act_runner invokes
|
||||
# `sh -e` (dash on the ci-python:3.14 image, which has no bash on
|
||||
# the default PATH for /bin/sh). Previous `[[ ]]` form failed
|
||||
# silently — only the SHA tag got appended, so :dev / :latest
|
||||
# never updated in the registry and the deployed stack kept
|
||||
# pulling stale images. Verified via `[[: not found` lines in
|
||||
# the runner log on commit 2a374d9.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
BUILD_VERSION="dev"
|
||||
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
fi
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
;;
|
||||
esac
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
2298268
|
||||
632037
|
||||
|
||||
+2
-15
@@ -8,27 +8,14 @@ COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Python runtime
|
||||
FROM python:3.12-slim AS runtime
|
||||
# Tracks CI image (ci-python:3.14) so test results stay representative.
|
||||
FROM python:3.14-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml .
|
||||
COPY src/ src/
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install .
|
||||
# Voice dependencies (faster-whisper, Kokoro TTS, soundfile) — activated at runtime via VOICE_ENABLED
|
||||
# Install CPU-only torch first so pip doesn't pull full CUDA wheels (~2 GB) for kokoro/transformers.
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu \
|
||||
&& pip install faster-whisper kokoro soundfile \
|
||||
&& python -m spacy download en_core_web_sm
|
||||
|
||||
# Build the fable-mcp wheel so it can be served for download
|
||||
COPY fable-mcp/ fable-mcp/
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install build hatchling \
|
||||
&& python -m build --wheel ./fable-mcp --outdir /app/dist/ \
|
||||
&& pip uninstall -y build \
|
||||
&& rm -rf fable-mcp/
|
||||
|
||||
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
|
||||
COPY alembic.ini .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fabled Assistant
|
||||
# Fabled Scribe
|
||||
|
||||
A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware.
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""rename briefing_date to day_date and drop existing briefing conversations
|
||||
|
||||
Revision ID: 0040
|
||||
Revises: 0039
|
||||
Create Date: 2026-04-25
|
||||
|
||||
This is a hard-cut migration accompanying the Journal feature. The user
|
||||
has accepted destruction of existing briefing data per the design spec.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0040"
|
||||
down_revision = "0039"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DELETE FROM conversations WHERE conversation_type = 'briefing'")
|
||||
op.alter_column("conversations", "briefing_date", new_column_name="day_date")
|
||||
op.execute("DELETE FROM settings WHERE key = 'briefing_config'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("conversations", "day_date", new_column_name="briefing_date")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""add moments + junction tables + moment embeddings
|
||||
|
||||
Revision ID: 0041
|
||||
Revises: 0040
|
||||
Create Date: 2026-04-25
|
||||
|
||||
People, Places, Tasks, and Notes all live in the `notes` table (distinguished
|
||||
by note_type and is_task). The four junction tables FK to notes(id) but stay
|
||||
separate so per-link-kind queries don't require a discriminator filter.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
|
||||
|
||||
revision = "0041"
|
||||
down_revision = "0040"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"moments",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conversation_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"source_message_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("day_date", sa.Date, nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"recorded_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("raw_excerpt", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"tags",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
sa.Column(
|
||||
"pinned",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_moments_user_day", "moments", ["user_id", "day_date"])
|
||||
op.create_index("ix_moments_user_occurred", "moments", ["user_id", "occurred_at"])
|
||||
|
||||
# Four junction tables, all FK to notes(id). Separate (vs. one merged
|
||||
# table with a discriminator) so per-kind queries don't need a filter.
|
||||
for table_name, fk_col in [
|
||||
("moment_people", "person_id"),
|
||||
("moment_places", "place_id"),
|
||||
("moment_tasks", "task_id"),
|
||||
("moment_notes", "note_id"),
|
||||
]:
|
||||
op.create_table(
|
||||
table_name,
|
||||
sa.Column(
|
||||
"moment_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
fk_col,
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
op.create_index(f"ix_{table_name}_{fk_col}", table_name, [fk_col])
|
||||
|
||||
op.create_table(
|
||||
"moment_embeddings",
|
||||
sa.Column(
|
||||
"moment_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column("user_id", sa.Integer, nullable=False),
|
||||
sa.Column("embedding", JSONB, nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_moment_embeddings_user", "moment_embeddings", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_moment_embeddings_user", table_name="moment_embeddings")
|
||||
op.drop_table("moment_embeddings")
|
||||
for table_name, fk_col in [
|
||||
("moment_notes", "note_id"),
|
||||
("moment_tasks", "task_id"),
|
||||
("moment_places", "place_id"),
|
||||
("moment_people", "person_id"),
|
||||
]:
|
||||
op.drop_index(f"ix_{table_name}_{fk_col}", table_name=table_name)
|
||||
op.drop_table(table_name)
|
||||
op.drop_index("ix_moments_user_occurred", table_name="moments")
|
||||
op.drop_index("ix_moments_user_day", table_name="moments")
|
||||
op.drop_table("moments")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""drop RSS infrastructure (tables + leftover briefing settings)
|
||||
|
||||
Revision ID: 0042
|
||||
Revises: 0041
|
||||
Create Date: 2026-04-26
|
||||
|
||||
Hard-cut removal of the RSS feature. Drops rss_feeds, rss_items,
|
||||
rss_item_reactions, rss_item_embeddings, and clears the leftover
|
||||
briefing-era settings rows that referenced RSS topic preferences.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0042"
|
||||
down_revision = "0041"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop in dependency order — children first.
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_embeddings CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_reactions CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_items CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_feeds CASCADE")
|
||||
op.execute(
|
||||
"DELETE FROM settings WHERE key IN "
|
||||
"('rss_enabled', 'briefing_include_topics', 'briefing_exclude_topics')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No downgrade — this is a destructive cleanup. The original RSS tables
|
||||
# would have to be recreated by replaying migrations 0026, 0028, 0035,
|
||||
# 0038, 0039 manually (and the data would not come back).
|
||||
raise NotImplementedError("RSS feature is permanently removed in 0042")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""replace events.end_dt with duration_minutes (Fable #160)
|
||||
|
||||
Revision ID: 0043
|
||||
Revises: 0042
|
||||
Create Date: 2026-04-29
|
||||
|
||||
Structural fix for the "end before start" bug class observed on prod
|
||||
2026-04-29: an event landed with end_dt 32 days before start_dt due
|
||||
to a tool-call mishap, then disappeared from upcoming-list filters.
|
||||
Storing duration instead of end_dt makes the invalid state
|
||||
inexpressible at the schema level (duration_minutes >= 0).
|
||||
|
||||
Backfill rules:
|
||||
- end_dt valid (end_dt > start_dt) → duration_minutes = total minutes
|
||||
- end_dt == start_dt → duration_minutes = 0 (zero-duration point)
|
||||
- end_dt NULL OR end_dt < start_dt → duration_minutes = NULL (corrupt
|
||||
or open-ended; treated as a point event from here on)
|
||||
|
||||
Existing API consumers continue to receive `end_dt` in responses — the
|
||||
field is now derived from `start_dt + duration_minutes` in
|
||||
``Event.to_dict()`` rather than stored.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0043"
|
||||
down_revision = "0042"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("duration_minutes", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"events_duration_minutes_non_negative",
|
||||
"events",
|
||||
"duration_minutes IS NULL OR duration_minutes >= 0",
|
||||
)
|
||||
# Backfill: convert valid end_dt into a minute count; leave NULL for
|
||||
# corrupt or absent end_dt. Bad rows (end_dt <= start_dt) collapse
|
||||
# cleanly to point events instead of forcing a recovery guess.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET duration_minutes = CAST(
|
||||
EXTRACT(EPOCH FROM (end_dt - start_dt)) / 60 AS INTEGER
|
||||
)
|
||||
WHERE end_dt IS NOT NULL AND end_dt >= start_dt
|
||||
"""
|
||||
)
|
||||
op.drop_column("events", "end_dt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("end_dt", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# Restore end_dt = start_dt + duration_minutes minutes for rows that
|
||||
# had a duration. NULL duration → NULL end_dt (point event).
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET end_dt = start_dt + (duration_minutes || ' minutes')::interval
|
||||
WHERE duration_minutes IS NOT NULL
|
||||
"""
|
||||
)
|
||||
op.drop_constraint("events_duration_minutes_non_negative", "events")
|
||||
op.drop_column("events", "duration_minutes")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add note.description and note.consolidated_at
|
||||
|
||||
Revision ID: 0044
|
||||
Revises: 0043
|
||||
Create Date: 2026-05-13
|
||||
|
||||
Adds two columns to the ``notes`` table to support the task-as-durable-record
|
||||
design (spec 2026-05-13):
|
||||
|
||||
- ``description``: user-stated goal / initial context. Meaningful when
|
||||
``is_task=true``; left NULL on knowledge notes.
|
||||
- ``consolidated_at``: timestamp of the most recent auto-summary pass. NULL
|
||||
until the first consolidation runs.
|
||||
|
||||
Backfill: for existing tasks we copy ``body`` into ``description`` so the
|
||||
user's original goal text is preserved when ``body`` is later overwritten by
|
||||
the auto-summary pipeline. The brief duplication window between
|
||||
``body`` and ``description`` is harmless and resolves on the first
|
||||
consolidation pass.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0044"
|
||||
down_revision = "0043"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("description", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# is_task is a Python property (status IS NOT NULL); there's no DB column
|
||||
# of that name. Backfill description from body for everything that
|
||||
# qualifies as a task at the model layer.
|
||||
op.execute(
|
||||
"UPDATE notes SET description = body "
|
||||
"WHERE status IS NOT NULL AND body IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("notes", "consolidated_at")
|
||||
op.drop_column("notes", "description")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add pin_kind and pin_label to note_versions
|
||||
|
||||
Revision ID: 0045
|
||||
Revises: 0044
|
||||
Create Date: 2026-05-13
|
||||
|
||||
Two additive columns on note_versions to support tiered retention:
|
||||
|
||||
- pin_kind: NULL (rolling autosave), 'auto' (system-declared via stability
|
||||
scan), 'manual' (user-declared with optional commit-note label).
|
||||
- pin_label: NULL for rolling. Auto-generated for 'auto'; user-supplied
|
||||
for 'manual' (may be NULL).
|
||||
|
||||
No backfill — every existing row stays rolling. The auto-pin scan
|
||||
(services/version_pinning_scheduler.py, daily 03:00 UTC) will catch up on
|
||||
the first scheduled run after deploy.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0045"
|
||||
down_revision = "0044"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("note_versions", sa.Column("pin_kind", sa.Text(), nullable=True))
|
||||
op.add_column("note_versions", sa.Column("pin_label", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("note_versions", "pin_label")
|
||||
op.drop_column("note_versions", "pin_kind")
|
||||
@@ -0,0 +1,103 @@
|
||||
"""generation_tool_log: per-turn tool-call telemetry
|
||||
|
||||
Revision ID: 0046
|
||||
Revises: 0045
|
||||
Create Date: 2026-05-21
|
||||
|
||||
Captures one row per assistant turn, recording which tools the model
|
||||
could have used, which it attempted, which fired successfully, and
|
||||
which failed (with error details). The empirical surface for
|
||||
evaluating model swaps and answering "does model X actually fire
|
||||
record_moment when it should?" without relying on anecdote.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
|
||||
|
||||
revision = "0046"
|
||||
down_revision = "0045"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"generation_tool_log",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conv_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
# SET NULL (not CASCADE) so the telemetry row survives if the
|
||||
# underlying assistant message is later deleted — we want to keep
|
||||
# the per-turn outcome record for retrospective analysis.
|
||||
sa.Column(
|
||||
"assistant_message_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("model", sa.Text, nullable=False),
|
||||
sa.Column("think_enabled", sa.Boolean, nullable=False),
|
||||
sa.Column(
|
||||
"tools_available",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
sa.Column(
|
||||
"tools_attempted",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
sa.Column(
|
||||
"tools_succeeded",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
# JSONB array of {name, error} objects so failed-tool details are
|
||||
# queryable without a separate failure table.
|
||||
sa.Column(
|
||||
"tools_failed",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::jsonb"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
# Common query: "recent tool outcomes for this user, filterable by model."
|
||||
op.create_index(
|
||||
"ix_generation_tool_log_user_created",
|
||||
"generation_tool_log",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
)
|
||||
# Per-conversation lookup for the journal page's own retrospection.
|
||||
op.create_index(
|
||||
"ix_generation_tool_log_conv",
|
||||
"generation_tool_log",
|
||||
["conv_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tool_log_conv", table_name="generation_tool_log")
|
||||
op.drop_index(
|
||||
"ix_generation_tool_log_user_created", table_name="generation_tool_log"
|
||||
)
|
||||
op.drop_table("generation_tool_log")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""reset voice_tts_voice and voice_tts_blend settings for piper migration
|
||||
|
||||
Revision ID: 0047
|
||||
Revises: 0046
|
||||
Create Date: 2026-05-22
|
||||
|
||||
The TTS backend swapped from kokoro to piper. Kokoro voice IDs
|
||||
(`af_heart`, `am_adam`, etc.) don't map to piper voice files
|
||||
(`en_US-amy-medium`, etc.) and there's no sensible auto-translation.
|
||||
Clear the stored voice selection so every user falls back to the piper
|
||||
default the next time they synthesize.
|
||||
|
||||
`voice_tts_blend` goes away entirely — piper has no voice-blending
|
||||
equivalent. The TTS service accepts the field for backward compat but
|
||||
ignores it; clearing the rows makes the DB match reality.
|
||||
|
||||
Both settings re-default cleanly on read, so this migration just deletes
|
||||
the rows without backfilling anything.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0047"
|
||||
down_revision = "0046"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"DELETE FROM settings WHERE key IN ('voice_tts_voice', 'voice_tts_blend')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No-op. The deleted settings just re-default on read; restoring the
|
||||
# kokoro IDs wouldn't help anyway since kokoro is gone.
|
||||
pass
|
||||
@@ -0,0 +1,47 @@
|
||||
"""conversations.last_curator_run_at — tracks scheduler progress per-conversation
|
||||
|
||||
Revision ID: 0048
|
||||
Revises: 0047
|
||||
Create Date: 2026-05-22
|
||||
|
||||
Phase 2 of the conversation+curator architecture (Fable #172). The
|
||||
scheduler runs every 15 minutes and processes any journal conversation
|
||||
with messages newer than its `last_curator_run_at` timestamp. NULL
|
||||
means "never run; process all of today on first sweep."
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0048"
|
||||
down_revision = "0047"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"conversations",
|
||||
sa.Column(
|
||||
"last_curator_run_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
# Indexed because the scheduler's selection query is
|
||||
# WHERE conversation_type='journal' AND (last_curator_run_at IS NULL OR ...)
|
||||
# which benefits from a partial index narrowed to journal rows.
|
||||
op.create_index(
|
||||
"ix_conversations_journal_last_curator",
|
||||
"conversations",
|
||||
["last_curator_run_at"],
|
||||
postgresql_where=sa.text("conversation_type = 'journal'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_conversations_journal_last_curator",
|
||||
table_name="conversations",
|
||||
)
|
||||
op.drop_column("conversations", "last_curator_run_at")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""conversations.curator_summary — feeds curator output back to the chat model
|
||||
|
||||
Revision ID: 0049
|
||||
Revises: 0048
|
||||
Create Date: 2026-05-22
|
||||
|
||||
Phase 3 of the conversation+curator architecture (Fable #172). The
|
||||
curator's final summary line (≤ 240 chars) is persisted here so the
|
||||
NEXT chat turn can include it in the system prompt. This is the
|
||||
feedback loop that closes the architecture — without it, the chat
|
||||
model has no awareness of what the curator extracted, and conversations
|
||||
risk circling back to topics already captured.
|
||||
|
||||
NULL means "no curator pass has produced a summary yet." The chat
|
||||
pipeline reads this column when building the journal system prompt;
|
||||
missing column = no injection, no failure.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0049"
|
||||
down_revision = "0048"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"conversations",
|
||||
sa.Column("curator_summary", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("conversations", "curator_summary")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""drop stored think_enabled rows — setting was removed
|
||||
|
||||
Revision ID: 0050
|
||||
Revises: 0049
|
||||
Create Date: 2026-05-23
|
||||
|
||||
The `think_enabled` user setting was retired with the chat+curator
|
||||
architecture: chat has tools=[] and curator hardcodes think=False, so
|
||||
the toggle was dead weight. Any rows already in `settings` for that
|
||||
key are now unread by the app; this migration clears them.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0050"
|
||||
down_revision = "0049"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DELETE FROM settings WHERE key = 'think_enabled'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No-op. The setting is dead; restoring NULL rows wouldn't help.
|
||||
pass
|
||||
@@ -0,0 +1,98 @@
|
||||
"""pending_curator_actions — curator-proposed mutations awaiting user approval
|
||||
|
||||
Revision ID: 0051
|
||||
Revises: 0050
|
||||
Create Date: 2026-05-23
|
||||
|
||||
Architecture: the curator runs unattended and can be confidently wrong.
|
||||
Additive operations (create_*, record_moment, log_work, save_person)
|
||||
land directly because adds are easily undone. Mutating operations
|
||||
(update_*, delete_*) are proposed to this table instead — the user
|
||||
sees them in a "Needs Review" panel and approves or rejects each one.
|
||||
|
||||
A proposed action stores:
|
||||
- The action type (`update_task`, `delete_note`, etc.) — drives which
|
||||
handler gets re-executed on approval.
|
||||
- The target id + type for display ("update task 42", "delete note 17").
|
||||
- The proposed arguments (`payload`) — what the curator wanted to do.
|
||||
- A snapshot of the target's state at proposal time (`current_snapshot`)
|
||||
— so the review UI can render a real before/after diff even if other
|
||||
work modified the entity since.
|
||||
- The status (`pending` / `approved` / `rejected`) and review timestamp.
|
||||
|
||||
Approval replays the original tool call via execute_tool with
|
||||
authority="user" so the request bypasses the curator interceptor and
|
||||
just runs.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0051"
|
||||
down_revision = "0050"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"pending_curator_actions",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conv_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("action_type", sa.Text, nullable=False),
|
||||
sa.Column("target_type", sa.Text, nullable=True),
|
||||
sa.Column("target_id", sa.Integer, nullable=True),
|
||||
sa.Column("target_label", sa.Text, nullable=True),
|
||||
sa.Column("payload", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column(
|
||||
"current_snapshot",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Text,
|
||||
nullable=False,
|
||||
server_default=sa.text("'pending'"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'approved', 'rejected')",
|
||||
name="pending_curator_actions_status_check",
|
||||
),
|
||||
)
|
||||
# Pending-only index — the Needs Review panel fetches "user's pending"
|
||||
# constantly, history rows just accumulate.
|
||||
op.create_index(
|
||||
"ix_pending_curator_actions_user_pending",
|
||||
"pending_curator_actions",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
postgresql_where=sa.text("status = 'pending'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_pending_curator_actions_user_pending",
|
||||
table_name="pending_curator_actions",
|
||||
)
|
||||
op.drop_table("pending_curator_actions")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""clear note_embeddings for fastembed swap
|
||||
|
||||
Revision ID: 0052
|
||||
Revises: 0051
|
||||
Create Date: 2026-05-26
|
||||
|
||||
Embeddings stored in `note_embeddings.embedding` (JSONB) were generated by
|
||||
Ollama's nomic-embed-text model (768-dim). The fastembed swap uses
|
||||
BAAI/bge-small-en-v1.5 (384-dim). Mixed-dim vectors would break the
|
||||
Python-side cosine similarity (length mismatch), so we wipe the table.
|
||||
|
||||
The startup hook `services.embeddings.backfill_note_embeddings` regenerates
|
||||
everything at the new dimension on next boot. There's no column-type change
|
||||
because the column is JSONB — dimensionless on the storage side.
|
||||
|
||||
Downgrade is the same operation: a fresh deployment of the prior code would
|
||||
backfill with the old model. No data is lost that the next boot won't restore.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0052"
|
||||
down_revision = "0051"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DELETE FROM note_embeddings")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM note_embeddings")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""drop chat / journal / push / curator / weather tables
|
||||
|
||||
Revision ID: 0053
|
||||
Revises: 0052
|
||||
Create Date: 2026-05-27
|
||||
|
||||
Phase 9 of the MCP-first pivot. The Python models for these tables were
|
||||
deleted in Phase 8 (commit 91bafb6); this migration drops the orphan
|
||||
SQL tables and cleans up dead per-user setting rows.
|
||||
|
||||
Hard-cutover: existing rows in these tables are discarded. The
|
||||
accompanying spec at docs/superpowers/specs/2026-05-26-mcp-first-pivot-design.md
|
||||
explicitly accepted this loss.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0053"
|
||||
down_revision = "0052"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# Order: junction tables first so the parent tables don't trip FK drops
|
||||
# even with CASCADE — keeps the migration readable.
|
||||
_DEAD_TABLES = [
|
||||
# Moments + junction tables (journal entity links)
|
||||
"moment_notes",
|
||||
"moment_tasks",
|
||||
"moment_places",
|
||||
"moment_people",
|
||||
"moment_embeddings",
|
||||
"moments",
|
||||
# Chat / generation telemetry
|
||||
"generation_tool_log",
|
||||
"messages",
|
||||
"conversations",
|
||||
# Curator approval queue
|
||||
"pending_curator_actions",
|
||||
# Push notifications
|
||||
"push_subscriptions",
|
||||
# Weather cache (journal-prep background fetcher)
|
||||
"weather_cache",
|
||||
# Legacy RSS-item embeddings (pre-MCP pivot, briefly experimented with)
|
||||
"rss_item_embeddings",
|
||||
]
|
||||
|
||||
|
||||
# Specific setting keys to drop. Anything matching the LIKE patterns in
|
||||
# upgrade() is also nuked; the explicit list catches one-off keys that
|
||||
# don't fit the namespaced patterns.
|
||||
_DEAD_SETTING_KEYS = [
|
||||
"default_model",
|
||||
"background_model",
|
||||
"assistant_name",
|
||||
"auto_consolidate_tasks",
|
||||
"chat_retention_days",
|
||||
"think_enabled",
|
||||
"rag_default_scope",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _DEAD_TABLES:
|
||||
op.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
|
||||
|
||||
keys_csv = ",".join(f"'{k}'" for k in _DEAD_SETTING_KEYS)
|
||||
op.execute(
|
||||
"DELETE FROM settings WHERE "
|
||||
"key LIKE 'voice_%' OR "
|
||||
"key LIKE 'journal_%' OR "
|
||||
"key LIKE 'briefing_%' OR "
|
||||
"key LIKE 'curator_%' OR "
|
||||
f"key IN ({keys_csv})"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise NotImplementedError(
|
||||
"No downgrade — hard cutover per the MCP-first pivot spec. "
|
||||
"Rolling back is not supported at the database layer."
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""drop image_cache table
|
||||
|
||||
Revision ID: 0054
|
||||
Revises: 0053
|
||||
Create Date: 2026-05-27
|
||||
|
||||
The image cache was wired into the LLM image-search tool (removed in Phase 8).
|
||||
With no producer or consumer left, the table is dropped here.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0054"
|
||||
down_revision = "0053"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS image_cache CASCADE")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise NotImplementedError(
|
||||
"No downgrade — hard cutover per the MCP-first pivot spec."
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""rulebook tables: rulebooks, rulebook_topics, rules, project_rulebook_subscriptions
|
||||
|
||||
Revision ID: 0055
|
||||
Revises: 0054
|
||||
Create Date: 2026-05-27
|
||||
|
||||
Adds the Scribe Rulebook hierarchy as a fourth top-level entity, sibling
|
||||
to Project. Rules carry a structural (statement, why, how_to_apply)
|
||||
triple. Projects subscribe to Rulebooks (many-to-many). See the design
|
||||
doc at docs/superpowers/specs/2026-05-27-scribe-rulebook-design.md.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0055"
|
||||
down_revision = "0054"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rulebooks",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True),
|
||||
sa.Column(
|
||||
"owner_user_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text, nullable=False),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_rulebooks_owner", "rulebooks", ["owner_user_id"])
|
||||
|
||||
op.create_table(
|
||||
"rulebook_topics",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True),
|
||||
sa.Column(
|
||||
"rulebook_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text, nullable=False),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column(
|
||||
"order_index", sa.Integer, nullable=False, server_default="0",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_rulebook_topics_rulebook", "rulebook_topics", ["rulebook_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"rules",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True),
|
||||
sa.Column(
|
||||
"topic_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text, nullable=False),
|
||||
sa.Column("statement", sa.Text, nullable=False),
|
||||
sa.Column("why", sa.Text),
|
||||
sa.Column("how_to_apply", sa.Text),
|
||||
sa.Column(
|
||||
"order_index", sa.Integer, nullable=False, server_default="0",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"),
|
||||
)
|
||||
op.create_index("ix_rules_topic", "rules", ["topic_id"])
|
||||
|
||||
op.create_table(
|
||||
"project_rulebook_subscriptions",
|
||||
sa.Column(
|
||||
"project_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"rulebook_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("project_id", "rulebook_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_project_rulebook_subs_rulebook",
|
||||
"project_rulebook_subscriptions",
|
||||
["rulebook_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_project_rulebook_subs_rulebook",
|
||||
table_name="project_rulebook_subscriptions",
|
||||
)
|
||||
op.drop_table("project_rulebook_subscriptions")
|
||||
op.drop_index("ix_rules_topic", table_name="rules")
|
||||
op.drop_table("rules")
|
||||
op.drop_index("ix_rulebook_topics_rulebook", table_name="rulebook_topics")
|
||||
op.drop_table("rulebook_topics")
|
||||
op.drop_index("ix_rulebooks_owner", table_name="rulebooks")
|
||||
op.drop_table("rulebooks")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""task_kind column on notes: distinguishes plan-tasks from work-tasks
|
||||
|
||||
Revision ID: 0056
|
||||
Revises: 0055
|
||||
Create Date: 2026-05-28
|
||||
|
||||
A plan is a Task (a note with non-null status) marked task_kind='plan'.
|
||||
The CHECK constraint lands in the same migration as the value set, per the
|
||||
'new CHECK-enum values need a same-change migration' rule.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0056"
|
||||
down_revision = "0055"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column(
|
||||
"task_kind", sa.Text(), nullable=False, server_default="work",
|
||||
),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.drop_column("notes", "task_kind")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""soft-delete: deleted_at + deleted_batch_id on 7 tables
|
||||
|
||||
Revision ID: 0057
|
||||
Revises: 0056
|
||||
Create Date: 2026-05-28
|
||||
|
||||
Recoverable deletes: each soft-deletable table gains a nullable deleted_at
|
||||
timestamp (NULL = live) and a deleted_batch_id (text UUID) stamped per delete
|
||||
operation so a cascaded delete restores as a unit. A daily cron purges rows
|
||||
past the retention window.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0057"
|
||||
down_revision = "0056"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLES = (
|
||||
"notes", "events", "projects", "milestones",
|
||||
"rulebooks", "rulebook_topics", "rules",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for t in _TABLES:
|
||||
op.add_column(t, sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column(t, sa.Column("deleted_batch_id", sa.Text(), nullable=True))
|
||||
op.create_index(f"ix_{t}_deleted_at", t, ["deleted_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for t in reversed(_TABLES):
|
||||
op.drop_index(f"ix_{t}_deleted_at", table_name=t)
|
||||
op.drop_column(t, "deleted_batch_id")
|
||||
op.drop_column(t, "deleted_at")
|
||||
@@ -0,0 +1,49 @@
|
||||
# CI Requirements — FabledScribe
|
||||
|
||||
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
||||
> in the CI-Runner repo.
|
||||
|
||||
## Runtime image
|
||||
|
||||
```
|
||||
git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
```
|
||||
|
||||
Used by all four jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
|
||||
lint (ruff), test (pytest), build (docker buildx).
|
||||
|
||||
## Image deps used
|
||||
|
||||
- python 3.14
|
||||
- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the
|
||||
frontend builder stage inside the production `Dockerfile`)
|
||||
- ruff (lint job runs `ruff check src/` with zero install overhead)
|
||||
- docker CLI + buildx (build job pushes the production image to the
|
||||
Forgejo registry)
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
Anything CI installs at job time that isn't in the image. Promotion
|
||||
candidates if more than one project needs them.
|
||||
|
||||
- `uv` — installed inline in the test job (`curl -LsSf
|
||||
https://astral.sh/uv/install.sh | sh`). **Temporary**: belongs in the
|
||||
ci-python image so every consumer doesn't re-install on cold start.
|
||||
Tracked at [CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner).
|
||||
- `http-ece` is `--no-build-isolation`-installed before the editable
|
||||
package install because http-ece doesn't declare `setuptools` as a
|
||||
build dep and uv creates bare venvs without it. Not promotion-worthy
|
||||
(one project, one wheel).
|
||||
|
||||
## Notes
|
||||
|
||||
- Production runtime image (`Dockerfile`) also tracks Python 3.14 — the
|
||||
CI image and runtime image stay aligned by design so test results are
|
||||
representative.
|
||||
- Build wall time: dominated by `pytest` (full async test suite). Cold
|
||||
ci-python pulls add ~30s; not a blocker.
|
||||
- Registry-backed BuildKit layer cache (`type=registry,ref=…:cache,mode=max`)
|
||||
gives ~80% speedup on warm builds — see the build job comment.
|
||||
- `pyproject.toml` pins `requires-python = ">=3.14"` to match the CI +
|
||||
runtime target; lockfile (`uv.lock`) is committed and resolves against
|
||||
Python 3.14.
|
||||
+9
-35
@@ -4,8 +4,6 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
TRUST_PROXY_HEADERS: "true"
|
||||
SECURE_COOKIES: "true"
|
||||
@@ -24,6 +22,7 @@ services:
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
stop_grace_period: 120s
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
environment:
|
||||
@@ -32,49 +31,24 @@ services:
|
||||
POSTGRES_DB: fabledassistant
|
||||
networks:
|
||||
- fabledassistant_backend
|
||||
# Lenient by design: a transient host exec/healthcheck stall (incident:
|
||||
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
|
||||
# escalate to killing the DB. Health here only gates app startup order.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fabled"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 5
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
networks:
|
||||
- fabledassistant_backend
|
||||
environment:
|
||||
OLLAMA_MAX_LOADED_MODELS: "2"
|
||||
OLLAMA_KEEP_ALIVE: "30m"
|
||||
OLLAMA_FLASH_ATTENTION: "1"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "ollama list || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
deploy:
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == worker
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 5
|
||||
delay: 10s
|
||||
max_attempts: 0
|
||||
window: 120s
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
ollama_models:
|
||||
|
||||
networks:
|
||||
fabledassistant_backend:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fabled Assistant — Quick Start
|
||||
# Fabled Scribe — Quick Start
|
||||
#
|
||||
# No build required. Pulls the latest pre-built image from the registry.
|
||||
#
|
||||
@@ -6,29 +6,26 @@
|
||||
# 1. Download this file
|
||||
# 2. docker compose -f docker-compose.quickstart.yml up -d
|
||||
# 3. Open http://localhost:5000 — the first account registered becomes admin
|
||||
# 4. Go to Settings → General to pull an LLM model (qwen3:8b or llama3.1:8b are good starting points)
|
||||
# 4. Go to Settings → MCP Access and connect Claude (Code or Desktop) via the
|
||||
# bearer-token URL shown there.
|
||||
#
|
||||
# Set SECRET_KEY via environment variable or a .env file alongside this file:
|
||||
# SECRET_KEY=your-random-secret-here
|
||||
|
||||
services:
|
||||
app:
|
||||
image: git.fabledsword.com/bvandeusen/fabledassistant:latest
|
||||
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
|
||||
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1:8b}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
volumes:
|
||||
- app_data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
@@ -39,44 +36,23 @@ services:
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
stop_grace_period: 120s
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: fabled
|
||||
POSTGRES_PASSWORD: fabled
|
||||
POSTGRES_DB: fabledassistant
|
||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
||||
# escalate to killing the DB. Health here only gates app startup order.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fabled"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
environment:
|
||||
OLLAMA_MAX_LOADED_MODELS: "2"
|
||||
OLLAMA_KEEP_ALIVE: "30m"
|
||||
OLLAMA_FLASH_ATTENTION: "1"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "ollama list > /dev/null 2>&1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
restart: unless-stopped
|
||||
# Uncomment to enable NVIDIA GPU passthrough (requires nvidia-container-toolkit):
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: all
|
||||
# capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
pgdata:
|
||||
ollama_models:
|
||||
|
||||
+10
-33
@@ -6,25 +6,16 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- app_data:/data
|
||||
# To use a bind mount instead (gives direct host access to all app data):
|
||||
# - ./data:/data
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:8B}"
|
||||
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
||||
# Uncomment and set to enable web research and image search via SearXNG:
|
||||
# Uncomment if you have a SearXNG instance you want to surface in the
|
||||
# Integrations tab as a configured web-search backend:
|
||||
# SEARXNG_URL: "http://searxng:8080"
|
||||
# IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path
|
||||
# IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed
|
||||
# Push notifications (VAPID keys - generate with: python -c "from py_vapid import Vapid01; v=Vapid01(); v.generate_keys(); print(v.private_key, v.public_key)")
|
||||
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
|
||||
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
|
||||
VAPID_CLAIMS_SUB: "${VAPID_CLAIMS_SUB:-mailto:admin@fabledassistant.local}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
interval: 10s
|
||||
@@ -34,37 +25,23 @@ services:
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
stop_grace_period: 120s
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-fabled}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-fabledassistant}
|
||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
||||
# escalate to killing the DB. Health here only gates app startup order.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
environment:
|
||||
OLLAMA_MAX_LOADED_MODELS: "2"
|
||||
OLLAMA_NUM_PARALLEL: "2"
|
||||
OLLAMA_KEEP_ALIVE: "30m"
|
||||
OLLAMA_FLASH_ATTENTION: "1"
|
||||
# GPU reservation commented out — no nvidia-container-toolkit on this host
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: all
|
||||
# capabilities: [gpu]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
ollama_models:
|
||||
app_data:
|
||||
|
||||
+13
-14
@@ -145,23 +145,22 @@ All endpoints require login (session cookie or `Authorization: Bearer <api-key>`
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
|
||||
|
||||
## Briefing
|
||||
## Journal
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/briefing/config` | Get briefing configuration |
|
||||
| PUT | `/api/briefing/config` | Save briefing configuration |
|
||||
| GET | `/api/briefing/feeds` | List RSS feeds |
|
||||
| POST | `/api/briefing/feeds` | Add RSS feed `{url, name?, category?}` |
|
||||
| DELETE | `/api/briefing/feeds/:id` | Delete feed |
|
||||
| POST | `/api/briefing/feeds/refresh` | Trigger immediate feed refresh → `{feeds_refreshed, new_items}` |
|
||||
| GET | `/api/briefing/weather` | Get weather configuration |
|
||||
| PUT | `/api/briefing/weather` | Save weather locations |
|
||||
| POST | `/api/briefing/weather/geocode` | Geocode address `{query}` → `{lat, lon, label}` |
|
||||
| POST | `/api/briefing/trigger` | Manually fire a briefing slot `{slot}` |
|
||||
| GET | `/api/briefing/conversations` | List past briefing conversations |
|
||||
| GET | `/api/briefing/conversations/today` | Get/create today's briefing conversation |
|
||||
| GET | `/api/briefing/conversations/:id/messages` | Get messages for a briefing conversation |
|
||||
| GET | `/api/journal/config` | Get journal configuration (locations, temp_unit, prep schedule) |
|
||||
| PUT | `/api/journal/config` | Save journal configuration; live-reschedules the prep job |
|
||||
| GET | `/api/journal/today` | Get/create today's journal conversation + messages |
|
||||
| GET | `/api/journal/day/:iso` | Get a specific day's journal conversation (read-only) |
|
||||
| GET | `/api/journal/days` | List dates with journal content, newest first |
|
||||
| POST | `/api/journal/trigger-prep` | Force-regenerate today's prep (or `{date}` for a specific day) |
|
||||
| GET | `/api/journal/weather` | Cached weather rows; auto-refreshes stale rows in the background |
|
||||
| GET | `/api/journal/weather/current` | Live current conditions for the primary configured location |
|
||||
| POST | `/api/journal/weather/refresh` | Manual refresh of all configured locations |
|
||||
| POST | `/api/journal/weather/geocode` | Geocode place name `{query}` → `{lat, lon, label}` |
|
||||
| POST | `/api/journal/moments/:id/update` | Update a recorded moment |
|
||||
| DELETE | `/api/journal/moments/:id` | Delete a moment |
|
||||
|
||||
## Settings
|
||||
|
||||
|
||||
+13
-14
@@ -133,11 +133,11 @@ Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
|
||||
|
||||
### Settings
|
||||
|
||||
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `briefing_enabled`, `briefing_locations`, `office_days`, etc.
|
||||
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `journal_config` (JSON: locations, temp_unit, prep schedule), `user_timezone`, `voice_*`, etc.
|
||||
|
||||
### Conversations / Messages
|
||||
|
||||
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`briefing`/`mcp`), `briefing_date`, `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
|
||||
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`journal`/`mcp`), `day_date` (YYYY-MM-DD for journal conversations), `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
|
||||
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
|
||||
|
||||
Title auto-generated by LLM on first exchange, re-generated every 10th message.
|
||||
@@ -155,11 +155,9 @@ Title auto-generated by LLM on first exchange, re-generated every 10th message.
|
||||
|
||||
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
|
||||
|
||||
### Briefing-Related Tables
|
||||
### Journal-Related Tables
|
||||
|
||||
`rss_feeds`: `id`, `user_id`, `url`, `name`, `category`, `last_fetched_at`.
|
||||
`rss_items`: `id`, `feed_id` FK, `guid`, `title`, `url`, `summary`, `pub_date`.
|
||||
`weather_cache`: per-user cache with `lat`, `lon`, `location_name`, `forecast_json`, `fetched_at`.
|
||||
`weather_cache`: per-user, per-`location_key` cache. Columns: `user_id`, `location_key` (`home`/`work`/etc.), `location_label`, `forecast_json` (Open-Meteo response), `previous_json` (last forecast, used to detect changes), `fetched_at`. Lat/lon are *not* stored on the cache row — they live in the user's `journal_config.locations.{home|work}` setting and are used at refresh time.
|
||||
|
||||
### API Keys
|
||||
|
||||
@@ -188,14 +186,14 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
|
||||
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
|
||||
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
|
||||
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm; briefing conversation routes |
|
||||
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm |
|
||||
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
|
||||
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
|
||||
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
|
||||
| `routes/projects.py` | Projects CRUD + summary endpoint |
|
||||
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
|
||||
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
|
||||
| `routes/briefing.py` | Briefing conversation + reply + history; RSS feed management |
|
||||
| `routes/journal.py` | Journal config CRUD; today/day/days conversation accessors; weather endpoints (cached/current/refresh/geocode); moments CRUD; trigger-prep |
|
||||
| `routes/groups.py` | Group CRUD + membership management (admin) |
|
||||
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
|
||||
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
|
||||
@@ -227,10 +225,11 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `services/groups.py` | Group CRUD; membership management |
|
||||
| `services/notifications.py` | Create/read/mark-read in-app notifications |
|
||||
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
|
||||
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → `GenerationBuffer` stream |
|
||||
| `services/briefing_scheduler.py` | APScheduler `BackgroundScheduler`; slots with catch-up logic; async-safe via `asyncio.create_task` |
|
||||
| `services/briefing_conversations.py` | Briefing conversation persistence and history queries |
|
||||
| `services/briefing_profile.py` | Per-user profile note that the assistant updates over time |
|
||||
| `services/journal_prep.py` | Deterministic data gather (tasks/events/weather/projects/recent moments/open threads) → LLM prose opener; persisted as the first assistant message of today's journal Conversation |
|
||||
| `services/journal_pipeline.py` | System-prompt builder for journal conversations; calls `build_profile_context()` so the LLM sees the user's profile + learned summary |
|
||||
| `services/journal_scheduler.py` | APScheduler `BackgroundScheduler`; per-user prep job; live-reschedule via `update_user_schedule()`; catch-up logic for missed runs |
|
||||
| `services/journal_search.py`, `services/moments.py` | Moment recording + search across journal history |
|
||||
| `services/user_profile.py` | `build_profile_context()` consolidates profile + observations + learned_summary into a system-prompt block |
|
||||
| `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes |
|
||||
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
|
||||
| `routes/events.py` | `/api/events` — event CRUD routes |
|
||||
@@ -293,8 +292,8 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
|
||||
| `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry |
|
||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
|
||||
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → briefing output |
|
||||
| `services/briefing_scheduler.py` | APScheduler integration, catch-up logic for missed slots |
|
||||
| `services/journal_prep.py` | Data gather → LLM prose opener; persisted into today's journal conversation |
|
||||
| `services/journal_scheduler.py` | APScheduler integration, per-user prep job, catch-up for missed runs |
|
||||
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
|
||||
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
|
||||
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
# FabledSword Design System
|
||||
|
||||
A house-style design system for the FabledSword family of self-hosted applications. FabledSword is the umbrella identity; individual apps share a common visual language but each carries its own signature accent color.
|
||||
|
||||
## Brand model
|
||||
|
||||
FabledSword is a **house style**, not a single brand. Apps share:
|
||||
|
||||
- Typography
|
||||
- Surfaces and dark-mode foundation
|
||||
- Component shapes (pills, cards, buttons)
|
||||
- Spacing system
|
||||
- Semantic colors (success, warning, error, info)
|
||||
- Action button colors (moss primary, bronze secondary)
|
||||
- Voice and tone
|
||||
|
||||
Each public app has its own **signature accent** used for: the wordmark, the app icon, active nav state, "you are here" indicators, cursor/selection color, and key brand moments. Accents do **not** appear on action buttons — those stay system-wide.
|
||||
|
||||
## Aesthetic direction
|
||||
|
||||
Modern mythic with heraldic restraint. Tech-forward execution, but the visual language borrows from manuscripts, heraldry, and forged objects rather than from gaming or fantasy iconography. Dark-mode-first because that's where these apps live.
|
||||
|
||||
The reference points: a well-printed book, a well-kept armory, a steward's ledger. Not: a fantasy novel cover, a tabletop RPG character sheet, a Renaissance Faire poster.
|
||||
|
||||
---
|
||||
|
||||
## Color system
|
||||
|
||||
### Universal surfaces (dark mode foundation)
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Obsidian | `#14171A` | Page background, deepest surface |
|
||||
| Iron | `#1E2228` | Card surfaces, raised elements |
|
||||
| Slate | `#2C313A` | Hovered surfaces, secondary elevation |
|
||||
| Pewter | `#3F4651` | Borders, dividers, ghost button outlines |
|
||||
|
||||
### Universal text and parchment
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Parchment | `#E8E4D8` | Primary text on dark surfaces |
|
||||
| Vellum | `#C2BFB4` | Secondary text, captions |
|
||||
| Ash | `#9C9A92` | Tertiary text, hints, metadata |
|
||||
|
||||
Parchment is intentionally not pure white — it's slightly warm to feel like aged paper. Pure white (#FFFFFF) is never used as text color.
|
||||
|
||||
### Universal action colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Moss | `#4A5D3F` | Primary action buttons (Save, Submit, Confirm) |
|
||||
| Bronze | `#8B7355` | Secondary action buttons (Cancel-but-not-destructive, alternate paths) |
|
||||
| Pewter | `#3F4651` | Tertiary/ghost buttons |
|
||||
|
||||
**Critical rule:** Action button colors are universal across all apps. A Save button in Scribe and a Save button in Minstrel look identical. Per-app accents do not appear on buttons.
|
||||
|
||||
### Semantic colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Success | `#4A5D3F` | Success states (same as Moss — they're aligned by design) |
|
||||
| Warning | `#8B6F1E` | Warnings, caution states |
|
||||
| Error | `#C04A1F` | Error messages, validation failures |
|
||||
| Info | `#3D5A6E` | Informational callouts |
|
||||
| Destructive | `#6B2118` | Destructive action buttons (Delete, Remove, irreversible) |
|
||||
|
||||
**Why error and destructive are different:** Error is the orange-red used in alerts and validation messages. Destructive (oxblood) is reserved for buttons that perform irreversible actions — it carries more weight precisely because it's used sparingly. Pair destructive buttons with an icon (trash, X) so color is reinforcement, not the only signal.
|
||||
|
||||
### Per-app signature accents
|
||||
|
||||
| App | Hex | Mood |
|
||||
|---|---|---|
|
||||
| Fabled Scribe | `#5B4A8A` | Dusty violet — ink, manuscripts |
|
||||
| Minstrel | `#4A6B5C` | Forest teal — music, performance |
|
||||
| Fabled Forge | `#8B5A2B` | Forge bronze — creation, craft |
|
||||
| Roundtable | `#4A5D7E` | Slate blue — stewardship, infrastructure |
|
||||
| FabledSword (umbrella) | `#6B2118` | Oxblood — house identity, ceremonial use only |
|
||||
|
||||
**Accent usage rules:**
|
||||
- The accent appears on the app's wordmark and icon.
|
||||
- The accent indicates active/current state in nav (the selected page, the active tab).
|
||||
- The accent is the cursor color and text-selection color in long-form surfaces (Scribe notes, Forge story drafts).
|
||||
- The accent does NOT appear on primary or secondary action buttons.
|
||||
- The accent does NOT appear in body text or chrome.
|
||||
- One accent per app. Don't mix accents within a single app.
|
||||
|
||||
### Color contrast
|
||||
|
||||
All text-on-surface combinations meet WCAG AA at minimum. Parchment on Obsidian is the maximum-contrast pairing; Vellum on Iron is the lowest-contrast pairing still considered acceptable for body text. Ash is for hints only — never load-bearing information.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Type families
|
||||
|
||||
| Family | Role | Source |
|
||||
|---|---|---|
|
||||
| Fraunces | Display, headings, wordmarks | Google Fonts |
|
||||
| Inter | Body, UI, labels | Google Fonts |
|
||||
| JetBrains Mono | Code, terminal output, monospaced data | Google Fonts |
|
||||
|
||||
### Why this pairing
|
||||
|
||||
Fraunces is a contemporary serif with personality — it has the warmth and authority of a book serif without feeling like costume. It signals "this is considered" without signaling "this is a fantasy product." Inter is the workhorse — neutral, ubiquitous, designed for screens, doesn't compete with the serif. JetBrains Mono is the natural choice for any developer-adjacent product and supports ligatures.
|
||||
|
||||
### Type scale
|
||||
|
||||
| Token | Size | Weight | Family | Usage |
|
||||
|---|---|---|---|---|
|
||||
| Display | 40px | 500 | Fraunces | App wordmark, hero text |
|
||||
| H1 | 32px | 500 | Fraunces | Page titles |
|
||||
| H2 | 24px | 500 | Fraunces | Section headings |
|
||||
| H3 | 18px | 500 | Inter | Subsection headings, card titles |
|
||||
| Body | 15px | 400 | Inter | Paragraphs, default text |
|
||||
| Body small | 13px | 400 | Inter | Captions, metadata |
|
||||
| Label | 12px | 500 | Inter | Buttons, form labels, badges |
|
||||
| Code | 13px | 400 | JetBrains Mono | Inline code, code blocks |
|
||||
| Tiny | 11px | 500 | Inter | Micro-labels (`UPPERCASE LETTERSPACED`) |
|
||||
|
||||
### Typography rules
|
||||
|
||||
- **Sentence case everywhere.** Never Title Case for headings, never ALL CAPS except for the Tiny micro-label style.
|
||||
- **Two weights only:** 400 regular and 500 medium. Never 600 or 700 — they read heavy in dark mode.
|
||||
- **Fraunces only at 18px and above.** Below that it loses too much detail and feels fragile. For h3 and below, use Inter.
|
||||
- **Line height** 1.5 for body, 1.3 for headings, 1.7 for long-form reading surfaces (Scribe notes, Forge drafts).
|
||||
- **Letter-spacing** at default for everything except the Tiny micro-label, which gets `0.08em` letter-spacing and uppercase styling.
|
||||
|
||||
---
|
||||
|
||||
## Spacing and layout
|
||||
|
||||
### Spacing scale (px)
|
||||
|
||||
`4, 8, 12, 16, 20, 24, 32, 48, 64, 96`
|
||||
|
||||
Use rem units for vertical rhythm in long-form content (paragraph spacing). Use px for component-internal spacing (padding, gaps).
|
||||
|
||||
### Border radius
|
||||
|
||||
| Token | Size | Usage |
|
||||
|---|---|---|
|
||||
| Small | 4px | Pills, tags, code spans |
|
||||
| Medium | 8px | Buttons, inputs, small cards |
|
||||
| Large | 12px | Cards, panels, modals |
|
||||
| Extra large | 16px | Hero containers, major surfaces |
|
||||
|
||||
### Borders
|
||||
|
||||
- Default border: `0.5px solid Pewter` (#3F4651)
|
||||
- Hovered/emphasized border: `0.5px solid Vellum` at 30% opacity
|
||||
- Featured/active border: `2px solid [accent]` (only for emphasizing a selected card or active tab)
|
||||
|
||||
The 0.5px default is deliberate — it reads as a hairline at most pixel densities and avoids the heavy "boxed-in" feeling that 1px+ borders create on dark backgrounds.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Buttons
|
||||
|
||||
| Variant | Background | Text | Border |
|
||||
|---|---|---|---|
|
||||
| Primary | Moss `#4A5D3F` | Parchment | None |
|
||||
| Secondary | Bronze `#8B7355` | Parchment | None |
|
||||
| Ghost | Transparent | Parchment | 0.5px Pewter |
|
||||
| Destructive | Oxblood `#6B2118` | Parchment | None — pair with icon |
|
||||
|
||||
Padding: `8px 16px` for default, `6px 12px` for compact, `10px 20px` for prominent. Border-radius: 8px. Font: Inter 12px/500 with default letter-spacing.
|
||||
|
||||
### Pills and tags
|
||||
|
||||
Used for tags, hashtags, code spans, status badges. Background is the accent color at ~15% opacity, text is the accent at full strength. Border-radius 4px, padding `2px 8px`, Inter 11px/500.
|
||||
|
||||
In Scribe specifically, hashtags and tags use the dusty violet accent. In Minstrel, they'd use forest teal. The pattern is shared; the color follows the app.
|
||||
|
||||
### Cards
|
||||
|
||||
- Background: Iron (#1E2228)
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 12px
|
||||
- Padding: 20px
|
||||
|
||||
For featured/selected cards, swap to a 2px solid accent border. Don't change the background.
|
||||
|
||||
### Inputs
|
||||
|
||||
- Background: Obsidian (#14171A) — darker than the page surface to feel "inset"
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 8px
|
||||
- Padding: 8px 12px
|
||||
- Focus state: 2px solid accent ring (using `box-shadow: 0 0 0 2px [accent]` to avoid layout shift)
|
||||
|
||||
### Code blocks
|
||||
|
||||
- Background: Obsidian (#14171A)
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 8px
|
||||
- Padding: 12px 16px
|
||||
- Font: JetBrains Mono 13px/400
|
||||
- Inline code: same family, with 4px-radius pill background using the app accent at 15% opacity
|
||||
|
||||
---
|
||||
|
||||
## The FabledSword lockup
|
||||
|
||||
A small, persistent FS mark appears in the navigation chrome of every app — the way an Apple logo persists across macOS apps. This is the only place oxblood appears in normal app usage.
|
||||
|
||||
**Specification:**
|
||||
- 16-20px height in nav contexts
|
||||
- Oxblood (#6B2118) on dark surfaces
|
||||
- Positioned in the bottom-left of nav rails or top-left when there's no rail
|
||||
- Hover/click reveals a small menu: link to other apps in the family, link to FabledSword.com, version info
|
||||
|
||||
The lockup itself is a small heraldic mark — a stylized FS monogram — *not* a literal sword icon. We're avoiding sword imagery in app chrome because it would clash with the restrained, modern-mythic aesthetic. The wordmark "FabledSword" appears only on the umbrella site and in About/Settings dialogs.
|
||||
|
||||
---
|
||||
|
||||
## Voice and tone
|
||||
|
||||
The FabledSword voice is **understated mythic** — it borrows the register of stewardship, craft, and considered making, but never tips into roleplay or affectation.
|
||||
|
||||
### Do
|
||||
|
||||
- Use plain language for everything functional. ("Save", "Cancel", "Add note")
|
||||
- Reserve flavored language for moments where the user is *waiting* or *failing* — loading states, empty states, error pages, 404s.
|
||||
- Borrow vocabulary from craft and stewardship: "draft", "ledger", "kept", "set aside", "to come", "in progress", "abandoned".
|
||||
- Be brief. The mythic register is undermined by verbosity.
|
||||
|
||||
### Don't
|
||||
|
||||
- Don't use thee/thou/thy or pseudo-archaic spelling.
|
||||
- Don't address the user as "traveler", "wanderer", "adventurer", or any RPG-adjacent epithet.
|
||||
- Don't use sword/blade/forge metaphors in error messages. ("Your save was forged successfully" — no.)
|
||||
- Don't make the user feel like they're playing a game when they're just trying to use software.
|
||||
|
||||
### Examples
|
||||
|
||||
| Context | Plain | FabledSword voice |
|
||||
|---|---|---|
|
||||
| Empty list | "No items yet" | "Nothing kept here yet." |
|
||||
| 404 | "Page not found" | "This page is not in the ledger." |
|
||||
| Loading | "Loading..." | "Fetching..." (just keep it plain — the mythic note is reserved for moments with more space) |
|
||||
| Save success | "Saved!" | "Saved." (plain — success doesn't need flavor) |
|
||||
| Save error | "Error saving" | "Couldn't save. The change has been kept locally — try again in a moment." |
|
||||
| Delete confirm | "Delete this?" | "Remove this from the ledger? This can't be undone." |
|
||||
|
||||
The pattern: action-adjacent language stays plain; absence/failure/waiting gets the flavor.
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
### Style
|
||||
|
||||
- Stroke-based, 1.5px stroke weight at 24px, 1px at 16px
|
||||
- Rounded line caps and joins
|
||||
- 24px or 16px grid
|
||||
- Outline style by default; filled style only for active/selected states
|
||||
|
||||
Use **Lucide** (https://lucide.dev) as the base icon set — it matches this style exactly and is open-source. Only commission custom icons for app-specific concepts that Lucide doesn't cover.
|
||||
|
||||
### Don't
|
||||
|
||||
- No filled icons in default UI (reserve for active states)
|
||||
- No icon styles that mix stroke and fill chaotically
|
||||
- No literal medieval imagery (swords, scrolls with curls, banners) in functional UI
|
||||
- No emoji as icons
|
||||
|
||||
---
|
||||
|
||||
## Per-app application
|
||||
|
||||
### Fabled Scribe (#5B4A8A — dusty violet)
|
||||
|
||||
A second-brain notes and task management tool. The accent appears in: the wordmark, hashtags and tag pills, the active nav item, text selection color, and the cursor in the editor. Notes are presented on Iron-surfaced cards with generous reading line-height. The hashtag system uses Scribe's accent for visual continuity.
|
||||
|
||||
### Minstrel (#4A6B5C — forest teal)
|
||||
|
||||
Self-hosted music. The accent appears on: now-playing indicators, active track highlights, the wordmark, equalizer/visualization elements. Album art dominates visually, so the accent should appear in chrome and metadata, never overlapping cover imagery.
|
||||
|
||||
### Fabled Forge (#8B5A2B — forge bronze)
|
||||
|
||||
Story-building and worldbuilding tool. The accent appears on: the wordmark, character/location/object markers in story trees, the editor cursor, "kept/canon" indicators distinguishing finalized story elements from drafts. This app benefits from Fraunces being used more aggressively — for entity titles, chapter headings, etc.
|
||||
|
||||
### Roundtable (#4A5D7E — slate blue)
|
||||
|
||||
Home server management. The accent appears on: the wordmark, healthy/online status indicators, the active dashboard panel border. Status colors here are critical — green for healthy, amber for warning, red (the orange-red error tone, not oxblood) for failed. The accent itself indicates "this is the panel I'm currently looking at."
|
||||
|
||||
**Naming note:** "Roundtable" leans the wrong direction — its connotation is *equal participants in discussion* rather than *one steward managing a domain*. Consider "Steward" or "Castellan" if you revisit naming. Castellan in particular is good — it specifically means "the officer in charge of a castle."
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes
|
||||
|
||||
### CSS custom properties
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--fs-obsidian: #14171A;
|
||||
--fs-iron: #1E2228;
|
||||
--fs-slate: #2C313A;
|
||||
--fs-pewter: #3F4651;
|
||||
|
||||
/* Text */
|
||||
--fs-parchment: #E8E4D8;
|
||||
--fs-vellum: #C2BFB4;
|
||||
--fs-ash: #9C9A92;
|
||||
|
||||
/* Action */
|
||||
--fs-moss: #4A5D3F;
|
||||
--fs-bronze: #8B7355;
|
||||
|
||||
/* Semantic */
|
||||
--fs-warning: #8B6F1E;
|
||||
--fs-error: #C04A1F;
|
||||
--fs-info: #3D5A6E;
|
||||
--fs-oxblood: #6B2118;
|
||||
|
||||
/* Typography */
|
||||
--fs-font-display: 'Fraunces', Georgia, serif;
|
||||
--fs-font-body: 'Inter', system-ui, sans-serif;
|
||||
--fs-font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* Layout */
|
||||
--fs-radius-sm: 4px;
|
||||
--fs-radius-md: 8px;
|
||||
--fs-radius-lg: 12px;
|
||||
--fs-radius-xl: 16px;
|
||||
}
|
||||
|
||||
/* Per-app accent — set ONE of these on the root for each app */
|
||||
[data-app="scribe"] { --fs-accent: #5B4A8A; }
|
||||
[data-app="minstrel"] { --fs-accent: #4A6B5C; }
|
||||
[data-app="forge"] { --fs-accent: #8B5A2B; }
|
||||
[data-app="roundtable"]{ --fs-accent: #4A5D7E; }
|
||||
```
|
||||
|
||||
### Tailwind integration
|
||||
|
||||
If using Tailwind, extend the theme with these tokens rather than relying on default colors. The default Tailwind palette will fight this system — you'll get drift back toward bright defaults if you don't lock down the palette explicitly.
|
||||
|
||||
---
|
||||
|
||||
## What this kit deliberately does NOT include
|
||||
|
||||
- **Logo files.** The lockup design is described conceptually but the actual mark needs to be drawn. Hire a designer or use Claude Design to iterate on a heraldic FS monogram.
|
||||
- **Marketing site design.** This kit is for application UI. The umbrella marketing site (FabledSword.com) can use this system but will need additional patterns (hero layouts, feature grids, etc.).
|
||||
- **Email templates.** Different constraints, different problem.
|
||||
- **Print collateral.** Not in scope.
|
||||
- **Mobile native app patterns.** This is web-first. iOS/Android conventions would override several choices here (button shapes, navigation patterns).
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 25, 2026. Iterate as the family of apps grows.*
|
||||
|
||||
---
|
||||
|
||||
# Scribe-specific decisions in progress
|
||||
|
||||
> This section tracks decisions made while adapting the FabledSword baseline above for Scribe specifically. Items here are *in progress* — once they feel solid, they get folded into the main body of the document (either as Scribe-specific extensions in the per-app section, or as updates to the universal rules where Scribe's needs reveal a gap in the baseline).
|
||||
|
||||
*Iteration started: 2026-04-26. Foundation pass shipped 2026-04-27 in `7a9a8b7` (palette, fonts, light mode, action tokens, hardcoded indigo cleanup, warm-gold deprecation). Surface phase shipped 2026-04-27 across `93a3beb` → `3c1ec40` (Lucide migration, Hybrid-rule button reclassification per surface, long-form line-height, two-weights-only). The system is now applied end-to-end; this section will fold into the main body once the result has had time to settle in real use.*
|
||||
|
||||
## Decisions made so far
|
||||
|
||||
### Accent footprint — Hybrid rule (not Strict)
|
||||
|
||||
The doc baseline says the per-app accent only appears on wordmark, active nav, cursor, and text selection — never on action buttons. Scribe currently uses indigo on essentially every interactive surface (CTAs, scrollbars, glows, borders, focus rings). Hard-cutting to the doc baseline would lose too much identity in one swing.
|
||||
|
||||
**Hybrid rule:** the accent reserves a slightly larger footprint than the doc baseline, but still much smaller than today.
|
||||
|
||||
- **Accent (dusty violet) lives on:** wordmark; active nav; cursor and text selection in editor surfaces; tags/pills/wikilinks; in-progress task badge; focus rings; **brand-moment CTAs** — chat Send, "Create note" empty-state CTA, journal Send, "Start journaling" empty-state.
|
||||
- **Moss (sage-green primary) lives on:** Save / Submit / Confirm in forms and modals; generic affirmative actions where the button just means "do this thing" with no brand pretense.
|
||||
- **Bronze (secondary):** Cancel-but-not-destructive, alternative paths.
|
||||
- **Oxblood (destructive):** Delete / Remove (paired with an icon).
|
||||
- **Pewter ghost:** tertiary actions, "later", "skip", "see also".
|
||||
|
||||
**Rule of thumb:** if the user is engaging with a *Scribe-feature moment* (sending a chat, opening a fresh note, jumping into the journal), accent. If they're just *operating the software* (saving an edit, confirming a dialog), Moss.
|
||||
|
||||
### Light mode — warm parchment, matched aesthetic
|
||||
|
||||
The doc is dark-only. Scribe today supports both light and dark, and we keep both. The light mode is derived to *match* the dark mode aesthetic rather than defaulting to system white-and-ink.
|
||||
|
||||
- Page background: in the `#F5F1E8` warm cream family (specific values TBD)
|
||||
- Cards: near-white but slightly tinted
|
||||
- Text: deep ink `#14171A` (mirroring Obsidian)
|
||||
- Accent: same dusty violet `#5B4A8A` (works on both themes)
|
||||
|
||||
The metaphor stays consistent across themes: ink on aged paper (light) ↔ parchment text on graphite (dark). Light mode is *not* the system standard look.
|
||||
|
||||
**Known downside:** warm parchment backgrounds can fight with embedded color content. Mitigation: code blocks get a slight cool wash in light mode specifically, to keep syntax highlighting readable.
|
||||
|
||||
### Status and priority palette — extend the doc's semantic set
|
||||
|
||||
The doc's semantic colors (Success / Warning / Error / Info / Destructive) are leaner than what Scribe needs for task management. Rather than running a parallel palette, Scribe extends the doc by mapping its status/priority tokens onto doc primitives where they fit and defining new app-level tokens for the rest.
|
||||
|
||||
**Status (task lifecycle):**
|
||||
|
||||
| Token | Color | Source | Logic |
|
||||
|---|---|---|---|
|
||||
| `status-todo` | Pewter `#3F4651` | doc | Neutral, "not started yet" |
|
||||
| `status-in-progress` | dusty violet `#5B4A8A` | accent | Active = brand moment per Hybrid |
|
||||
| `status-done` | Moss `#4A5D3F` | doc Success | Affirmative completion |
|
||||
| `status-cancelled` | Ash `#9C9A92` | doc | Faded, "let go" |
|
||||
| `status-paused` | Warning `#8B6F1E` | doc | Stalled, needs attention — replaces the old warm gold treatment |
|
||||
|
||||
**Priority (loudness scale):**
|
||||
|
||||
| Token | Color | Source | Logic |
|
||||
|---|---|---|---|
|
||||
| `priority-low` | Info `#3D5A6E` | doc | Cool, FYI — quietest end of the spectrum |
|
||||
| `priority-medium` | Warning `#8B6F1E` | doc | Golden, mid-attention |
|
||||
| `priority-high` | Error `#C04A1F` | doc | Terracotta, urgent |
|
||||
| `priority-none` | Vellum/Ash | doc | No signal |
|
||||
|
||||
The priority row reads as a clean cool→warm gradient (slate blue → golden brown → terracotta), which matches the semantic loudness — coherence the current ad-hoc palette doesn't have.
|
||||
|
||||
**Other functional tokens:**
|
||||
|
||||
| Token | Color | Logic |
|
||||
|---|---|---|
|
||||
| `wikilink` | dusty violet | Editorial brand moment per Hybrid |
|
||||
| `overdue` | Error `#C04A1F` | Same as priority-high — overdue IS a priority signal |
|
||||
| `toast-success` | Moss | doc semantic |
|
||||
| `toast-error` | Error | doc semantic |
|
||||
| `toast-info` | Info | doc semantic |
|
||||
| `tag-bg` / `tag-text` | accent at 15% / accent | Per doc pill recipe |
|
||||
|
||||
Each token gets a `*-bg` companion at low alpha (matching the existing pattern in `theme.css`).
|
||||
|
||||
**Removed:** the warm gold accent (`--color-accent-warm: #b8860b`). Its two jobs split:
|
||||
|
||||
- Dates and timestamps (knowledge cards, event details, chat) → use `text-secondary` instead. Dates are metadata, not a brand surface; muted is the correct register.
|
||||
- Paused project status → use the new `status-paused` (Warning `#8B6F1E`) row above. Same golden-brown family, semantically aligned.
|
||||
|
||||
### Typography — adopt the doc's stack and scale
|
||||
|
||||
Adopt the doc's type stack and scale verbatim, with one deferred verification (long-form line-height in practice).
|
||||
|
||||
- **Body font: Inter.** Replaces Scribe's current system-stack body font. Doc-defined; no Scribe-specific divergence.
|
||||
- **Type scale:** as in the doc table — Display 40 / H1 32 / H2 24 / H3 18 / Body 15 / Body small 13 / Label 12 / Code 13 / Tiny 11.
|
||||
- **Two weights only:** 400 regular, 500 medium. No 600/700 (reads heavy in dark mode and against the muted palette).
|
||||
- **Family rules:** Fraunces at 18px+ only (Display, H1, H2). H3 and below = Inter. Code = JetBrains Mono.
|
||||
- **Line height:** 1.5 body, 1.3 headings, **1.7 for long-form reading surfaces** (notes, journal entries).
|
||||
- **Sentence case for everything**, except the Tiny micro-label style which gets uppercase + 0.08em letter-spacing.
|
||||
|
||||
Mechanical rollout — value swaps in `theme.css` plus loading Inter and JetBrains Mono from Google Fonts (Fraunces is already loaded).
|
||||
|
||||
### Light mode — concrete palette
|
||||
|
||||
Fills in the warm-parchment direction picked earlier. Treat these as starting values; tune in practice.
|
||||
|
||||
| Token | Hex | Role |
|
||||
|---|---|---|
|
||||
| Page bg | `#F5F1E8` | Warm cream — the "paper" |
|
||||
| Card bg | `#FBF8F0` | Near-white, slightly warm — raised surfaces |
|
||||
| Inset bg (inputs, code) | `#EFEAE0` | Slightly darker than page, "inset" feeling |
|
||||
| Text primary | `#14171A` | Deep ink — Obsidian inverted |
|
||||
| Text secondary | `#5A5852` | Warm mid-grey |
|
||||
| Text muted | `#9A9890` | Warm light grey for hints/metadata |
|
||||
| Border default | `#D9D6CE` | Warm light pewter, hairline weight |
|
||||
|
||||
**Code-block exception:** in light mode specifically, code blocks use a slight cool wash (e.g. `#EBEDF0`) instead of the warm inset bg, so syntax highlighting reads cleanly. This is the mitigation for the "warm bg fights colored content" downside.
|
||||
|
||||
The accent (`#5B4A8A` dusty violet), Moss, Bronze, Oxblood, and the semantic color set are **identical across themes** — only the surface and text palettes flip.
|
||||
|
||||
### Chat-bubble codification — keep the Illuminated Transcript pattern
|
||||
|
||||
The existing chat-bubble pattern (informally called "Illuminated Transcript") gets written into the design system as a documented chat component. Other apps in the family that add a chat surface inherit the pattern; Scribe's existing implementation continues to work with only color shifts.
|
||||
|
||||
**User bubble (whisper):**
|
||||
- Background: transparent
|
||||
- Border: 0.5px Pewter (was: indigo-tinted)
|
||||
- Text color: secondary (Vellum dark / `#5A5852` light)
|
||||
- Right-aligned, rounded except bottom-right (subtle "from-me" tail)
|
||||
|
||||
**Assistant bubble (lit):**
|
||||
- Background: card surface (Iron dark / `#FBF8F0` light)
|
||||
- Border: none on top/right/bottom; **2px solid accent (dusty violet) on left edge only**
|
||||
- Box-shadow: accent-tinted glow + standard depth shadow (formula: `0 4px 28px rgba(<accent>, 0.14), 0 2px 8px rgba(0,0,0,0.4)` in dark; lower alphas in light)
|
||||
- Text color: primary (Parchment dark / Obsidian-inverted light)
|
||||
- Left-aligned, rounded except bottom-left
|
||||
|
||||
The 2px-accent left edge is the "illumination" — like an illuminated capital in a manuscript. The shadow is the lift. Together they make the assistant bubble read as the *primary* voice, while the user bubble is the *margin note*.
|
||||
|
||||
**Inline tool-call cards (`ToolCallCard`)** rendered inside an assistant bubble do NOT get their own border (per the border philosophy — the bubble already contains them). They use a slight surface tint to differentiate.
|
||||
|
||||
### Iconography — adopt Lucide, enforce a scale
|
||||
|
||||
Scribe currently hand-inlines SVG paths in 16+ Vue files, with 5 different stroke weights and 8+ different sizes. The visual style is already outline + rounded caps + `currentColor` stroke (matches the doc's intent), but there's no shared source and no scale discipline.
|
||||
|
||||
**Migration policy:**
|
||||
|
||||
1. **Install `lucide-vue-next`** as the icon source. Replace hand-inlined SVGs with imported components. Single source of truth.
|
||||
2. **Strict size scale: 16px and 24px only.** Today's mix of 12/13/14/15/17/18/20 collapses to those two. 16 for inline-with-text and small affordances; 24 for nav and primary actions.
|
||||
3. **Stroke weight per the doc: 1.5 at 24px, 1 at 16px.** Lighter than the current default of 2 — reads more refined, matches the muted palette philosophy. Overrides Lucide's default.
|
||||
4. **Outline by default; filled only for active/selected state.** Introduces a new affordance Scribe doesn't currently use — bookmark/pin/star icons can switch outline → filled to indicate active state. Reserve filled style strictly for this.
|
||||
5. **No emoji in chrome.** Replace the 3 files' emoji usage in UI labels/buttons/badges/empty states with Lucide equivalents. Emoji remain fine in *user content* (note bodies, chat messages the user typed).
|
||||
|
||||
Work cost: ~30-60 individual icon swaps across the 16 files. Mechanical; doesn't require redesign of any component.
|
||||
|
||||
### Voice and tone — adopt principles, defer formal audit
|
||||
|
||||
The doc's voice register applies to Scribe (understated mythic — plain for functional UI, flavored for empty/error/loading states). No formal sweep of every UI string yet.
|
||||
|
||||
**Approach:** apply the voice opportunistically as components are touched in the polish pass — when redesigning a settings tab, an empty state, or an error toast, rewrite the copy at the same time using the doc's register and examples table as the guide. A standalone audit pass is deferred unless drift becomes visible.
|
||||
|
||||
### Border philosophy — structural, not decorative
|
||||
|
||||
The doc treats borders as *structural* (Pewter neutral hairlines that say "boundary"), not decorative (Scribe today uses indigo-tinted borders that say "branded edge"). That principle suggests removing borders in places where surface tint and spacing already communicate separation.
|
||||
|
||||
**Borders to remove:**
|
||||
|
||||
- List rows (NotesListView, TasksListView, conversation history) — surface contrast + spacing should separate rows; current border reads as "boxed-in"
|
||||
- Inline `ToolCallCard` inside chat bubbles — the bubble is already a container; an extra border feels like double-wrapping
|
||||
- Filter chips and search-bar pills with a background tint — background does the work
|
||||
- Empty-state callouts with dashed/bordered "nothing here yet" boxes — tinted background reads cleaner
|
||||
|
||||
**Borders to keep (genuinely structural):**
|
||||
|
||||
- Standalone card containers (Notes viewer, Task viewer, the new daily prep card)
|
||||
- Modal / dialog edges
|
||||
- Code blocks (separates content type, not just space)
|
||||
- Focus rings (accessibility)
|
||||
- Major section dividers within a panel
|
||||
|
||||
Border weight is not load-bearing for Scribe — happy to use the doc's 0.5px hairline default; the *placement* discipline matters more than the weight.
|
||||
|
||||
## Open threads (next iterations)
|
||||
|
||||
### Foundation pass — shipped 2026-04-27 (`7a9a8b7`)
|
||||
|
||||
Mechanical token + font + light-mode rewrite of `frontend/src/assets/theme.css`, plus a sweep of hardcoded indigo and `--color-accent-warm` references across ~14 component files. Action tokens (`--color-action-primary` Moss, `--color-action-secondary` Bronze, `--color-action-destructive` Oxblood, `--color-action-ghost-border` Pewter) are defined but not yet applied — buttons still flow through `--color-primary` and read as dusty-violet gradients in the meantime, by design. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-foundation-design.md` (gitignored, local-only).
|
||||
|
||||
### Surface phase — shipped 2026-04-27 (`93a3beb` → `3c1ec40`)
|
||||
|
||||
Bundled as Hybrid (option C from the brainstorm): Lucide cross-cutting first, then surface-by-surface for the judgment work. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-surface-design.md` (gitignored, local-only). Seven PRs landed on `dev`:
|
||||
|
||||
| PR | Commit | Surface | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | `93a3beb` | Lucide cross-cutting | 60 hand-inlined SVGs across 15 files → `lucide-vue-next`. Every chrome icon at 16 or 24. Emoji-as-icons (`✕`, `✓`, `🎤`, `📎`, `↻`, `↑`, `×`, `☀`/`☾`) swept across the chrome. AppLogo wordmark and the GraphView D3 mount kept as the legitimate exceptions. |
|
||||
| 2 | `3d916d7` | Journal | Buttons audited; `↻` weather refresh → Lucide `RotateCcw`; assistant bubble line-height bumped; redundant `.journal-title` font-family dropped; dead `.news-section` CSS removed. |
|
||||
| 3 | `4192a64` | Chat | `.message-content` long-form 1.7 line-height on assistant bubbles; ToolCallCard outer border removed (bubble already contains it; error state moved to a left-edge accent); bulk-delete recolored to Oxblood with Trash2 icon; `.bulk-link` accent → muted text. |
|
||||
| 4 | `efb3534` | Knowledge cluster | `.prose` line-height bumped to 1.7 globally so all reading surfaces inherit. Save → Moss; Delete → Oxblood + Trash2; Edit / Advance → Moss; Convert / Share → Bronze. |
|
||||
| 5 | `ff498ce` | Project + Workspace | Project save panel → Moss; milestone confirm/cancel → Moss/Bronze; modal-btn-danger and per-row delete affordances → Oxblood; "Open Workspace" stays accent (project-surface brand moment). |
|
||||
| 6 | `541e2ed` | Settings | Densest button surface — every `.btn-save`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-danger-outline`, `.btn-toggle-open` reclassified per Hybrid; missing `.btn-danger` style block added (was unstyled before). |
|
||||
| 7 | `3c1ec40` | Edge surfaces | Calendar `New Event` → Moss; EventSlideOver Save/Cancel/Delete reclassified; HomeView hero CTA stays accent (brand moment); two-weights-only sweep across Header/Home/Calendar/Graph. |
|
||||
|
||||
**Cross-cutting changes folded in as the work touched files:**
|
||||
|
||||
- Long-form 1.7 line-height on `.prose` (PR 4) — applies to Note viewer, Task viewer, chat assistant bubbles, anywhere markdown renders into a reading surface.
|
||||
- Two-weights-only (400 + 500) — every `font-weight: 600` and `700` snapped to `500` across all surface PRs.
|
||||
- Hardcoded `--color-danger` in destructive button contexts → `--color-action-destructive` (Oxblood). `--color-danger` (Error terracotta) preserved for validation/error messages, per the doc's distinction between Error and Destructive.
|
||||
- Adjacent `×` / `✕` / unicode-arrow emoji swept opportunistically as files were touched (PRs 5, 7).
|
||||
|
||||
### Out of scope — deferred indefinitely
|
||||
|
||||
Items deliberately not addressed in this round; revisit when a real need surfaces:
|
||||
|
||||
- Lucide stroke-weight overrides (doc spec: 1.5 at 24, 1 at 16; current: Lucide default 2). Touched components if they read too heavy in practice.
|
||||
- Filled-as-active icon state — no current affordance uses it; introduce when bookmark/pin/star toggles are added.
|
||||
- Type-scale / spacing-scale CSS variables — components keep literal values.
|
||||
- Token rename to `--fs-*` namespace — Scribe is the only FabledSword app sharing this codebase.
|
||||
- FabledSword lockup placement — waiting on the actual heraldic mark to be drawn.
|
||||
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
||||
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
||||
|
||||
### Flutter app port — shipped 2026-04-28
|
||||
|
||||
The companion mobile app (`fabled_app` / FabledApp repo) tracks the same design system. Two commits:
|
||||
|
||||
- **Foundation port** — `0f05f47`. `lib/core/theme.dart` rewritten with the Obsidian/Iron/Pewter dark palette, warm parchment light palette, dusty violet `#5B4A8A` primary. Inter loaded for body, JetBrains Mono available at call sites, Fraunces for headlines ≥18px. New `ActionColors` ThemeExtension exposes Moss/Bronze/Oxblood/Pewter outside the `ColorScheme` (Material's primary/secondary/tertiary slots all carry brand accent, so action tokens need their own home). `GradientButton` recolored to dusty-violet gradient.
|
||||
- **Surface phase** — `b9e68e3`. `lucide_icons ^0.257.0` installed; 107 `Icons.*` references across 21 files swapped to `LucideIcons.*`. Input border radius 24 → 8 in both themes. ChatMessageBubble Illuminated Transcript fixes — neutral border on user bubbles, `surface`/Iron bg on assistant bubbles, asymmetric corner restoration (only bottom-left clipped, not both left corners), accent-tinted glow shadow added. 5 destructive confirm buttons across notes / tasks / chat / calendar wired to `ActionColors.destructive`. Calendar event Save wired to `ActionColors.primary` as the reference Moss site. 4 hardcoded indigo Color literals → dusty-violet equivalents.
|
||||
|
||||
The Flutter port doesn't decompose into 7 PRs the way web did because Flutter's centralized `theme.dart` means most palette/font work happens in one file. Per-screen Save / Cancel reclassification beyond the calendar event Save is opportunistic — the wiring pattern (`Theme.of(context).extension<ActionColors>()!.primary`) is established and applied incrementally as files are touched.
|
||||
|
||||
Pattern reference for downstream screens: see `lib/screens/calendar/event_form_sheet.dart` for `ActionColors.primary` usage on Save buttons; see the dialog spots in `note_edit_screen.dart` / `task_edit_screen.dart` / `note_detail_screen.dart` / `conversations_tab_screen.dart` for `ActionColors.destructive` on confirm-Delete buttons.
|
||||
|
||||
### Open threads
|
||||
|
||||
*New threads will accumulate here as gaps surface in real use.*
|
||||
+12
-6
@@ -57,7 +57,9 @@ Migration conventions:
|
||||
|
||||
### Pipeline
|
||||
|
||||
CI runs on Forgejo Actions with a custom runner base image (`py3.12-node22`):
|
||||
CI runs on Forgejo Actions, consuming the shared
|
||||
[`ci-python:3.14`](https://git.fabledsword.com/bvandeusen/CI-runner) image
|
||||
via `container.image` (Python 3.14 + Node 24 + ruff + uv + Docker CLI):
|
||||
|
||||
| Trigger | Jobs | Docker tags pushed |
|
||||
|---------|------|--------------------|
|
||||
@@ -77,13 +79,17 @@ CI runs on Forgejo Actions with a custom runner base image (`py3.12-node22`):
|
||||
git checkout dev && git merge main && git push origin dev
|
||||
```
|
||||
|
||||
### Custom Runner
|
||||
### Runner
|
||||
|
||||
Runner base image: `infra/Dockerfile.runner-base` (Ubuntu 24.04 + Python 3.12 + Node 22 LTS).
|
||||
Runner config: `infra/act-runner-config.yml` (label: `py3.12-node22`).
|
||||
Runner compose: `infra/runner-compose.yml`.
|
||||
CI jobs schedule against the `python-ci` runner label and run inside the
|
||||
shared `git.fabledsword.com/bvandeusen/ci-python:3.14` image (see
|
||||
`ci-requirements.md` for what this project relies on from the image).
|
||||
The runner deployment lives outside this repo; image bumps happen in
|
||||
[CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner) via Renovate.
|
||||
|
||||
To activate a new runner registration, copy `infra/act-runner-config.yml` to the runner's config directory, delete the `.runner` registration file in the runner container, and restart the stack.
|
||||
`infra/runner-compose.yml` + `infra/act-runner-config.yml` document the
|
||||
runner-host deployment shape; the source of truth is the deployed
|
||||
config on the runner host.
|
||||
|
||||
### Docker Registry
|
||||
|
||||
|
||||
+12
-9
@@ -52,19 +52,22 @@ Full conversation history with SSE streaming. Features:
|
||||
- **Bulk delete** — Select and delete multiple conversations.
|
||||
- **Retention** — Conversations auto-pruned after configurable days (default 90).
|
||||
|
||||
## Daily Briefing
|
||||
## Daily Journal
|
||||
|
||||
`/briefing` is a scheduled, dialogue-based morning briefing. The assistant compiles tasks, calendar events, projects, weather forecast, and RSS digest at configurable times, then checks in throughout the day. You can reply interactively.
|
||||
`/journal` is a conversational daily surface — each day is a chat-style conversation seeded with an LLM-generated daily prep as the first assistant message. The prep pulls together today's tasks, calendar events, weather, recent moments, and active projects in flowing prose, then invites the user to continue the conversation throughout the day.
|
||||
|
||||
**Schedule** — Configurable slots: morning (default 4am compile), midday (8am check-in), evening (12pm check-in), night (4pm). Scheduler catches up missed slots on startup.
|
||||
**Schedule** — Daily prep generates at a configurable time (default 5:00am). The "day rollover hour" controls when the journal switches to a new day (default 4am — late-night entries 1–3am still count as the previous day). Scheduler catches up missed runs on startup.
|
||||
|
||||
**Configuration** — Settings → Briefing: enable toggle, location geocoding, office days, time slot toggles, RSS feed management, push notification toggle.
|
||||
**Right rail** — The journal view shows current weather conditions and upcoming events for the next two weeks alongside the conversation. Both surfaces draw from the same data the prep references.
|
||||
|
||||
**RSS feeds** — Add feed URLs with optional name and category. Feeds are fetched and cached; the briefing digest includes recent items. Category badges shown in the UI. Feeds can be manually refreshed.
|
||||
**Configuration** — Settings → Profile:
|
||||
- *Locations* section: home and work place-name inputs (geocoded on blur via Nominatim) and a temperature unit toggle (C/F)
|
||||
- *Journal* section: prep auto-generate toggle, prep generation time, day rollover hour
|
||||
- *About You* / *Interests* / *Work Schedule* / *Response Preferences* feed personalization into the prep's system prompt
|
||||
|
||||
**Weather** — Location-based forecast via Open-Meteo. Multiple locations supported (home, work, or any city name). Geocoding via Nominatim.
|
||||
**Weather** — Location-based forecast via Open-Meteo. Up to two named locations (home, work). Cached rows auto-refresh in the background when the journal page loads.
|
||||
|
||||
**Profile note** — The assistant maintains a profile note for each user that it updates based on briefing conversations, improving personalisation over time.
|
||||
**What the assistant has learned** — The assistant maintains a per-user observation log + consolidated summary, generated from journal and chat conversations. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you over time.
|
||||
|
||||
## Web Research
|
||||
|
||||
@@ -115,10 +118,10 @@ Settings are tabbed:
|
||||
|-----|----------|
|
||||
| General | Assistant name, default model, model management (pull/delete) |
|
||||
| Account | Email change, password change, session invalidation |
|
||||
| Notifications | Push notification subscription, briefing push toggle |
|
||||
| Notifications | Push notification subscription, journal prep push toggle |
|
||||
| Profile | About you, response preferences, interests, work schedule, locations + temperature unit, journal prep schedule, learned observations |
|
||||
| Integrations | CalDAV configuration, SearXNG status |
|
||||
| Data | Personal export, backup/restore (admin) |
|
||||
| Briefing | Enable, location, office days, slots, RSS feeds, weather |
|
||||
| API Keys | Create/revoke API keys, Fable MCP download and install |
|
||||
| Config (admin) | Base URL, SMTP, OIDC settings |
|
||||
| Users (admin) | User list, invite links, registration toggle |
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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
|
||||
@@ -1,2 +0,0 @@
|
||||
FABLE_URL=http://localhost:5000
|
||||
FABLE_API_KEY=fmcp_your_key_here
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Async HTTP client for the Fable Assistant API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class FableAPIError(Exception):
|
||||
"""Raised when the Fable API returns a non-2xx response."""
|
||||
|
||||
def __init__(self, status_code: int, message: str) -> None:
|
||||
self.status_code = status_code
|
||||
super().__init__(f"Fable API error {status_code}: {message}")
|
||||
|
||||
|
||||
class FableClient:
|
||||
"""Async wrapper around httpx for the Fable REST API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
url = os.environ.get("FABLE_URL", "").rstrip("/")
|
||||
key = os.environ.get("FABLE_API_KEY", "")
|
||||
if not url:
|
||||
raise ValueError("FABLE_URL environment variable is required")
|
||||
if not key:
|
||||
raise ValueError("FABLE_API_KEY environment variable is required")
|
||||
self.base_url = url
|
||||
self._headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def __aenter__(self) -> "FableClient":
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=self._headers,
|
||||
timeout=httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=10.0),
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
raise RuntimeError("FableClient must be used as an async context manager")
|
||||
return self._client
|
||||
|
||||
@staticmethod
|
||||
def _raise_for_status(response: httpx.Response) -> None:
|
||||
if response.is_error:
|
||||
try:
|
||||
message = response.json().get("error", response.text)
|
||||
except Exception:
|
||||
message = response.text
|
||||
raise FableAPIError(response.status_code, message)
|
||||
|
||||
async def get(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().get(path, **kwargs)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
async def post(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().post(path, **kwargs)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
async def patch(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().patch(path, **kwargs)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
async def put(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().put(path, **kwargs)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
async def delete(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().delete(path, **kwargs)
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
self._raise_for_status(response)
|
||||
if response.content:
|
||||
return response.json()
|
||||
return None
|
||||
|
||||
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
|
||||
"""Yield non-empty lines from a streaming GET response (SSE)."""
|
||||
async with self._http().stream("GET", path, **kwargs) as response:
|
||||
if response.is_error:
|
||||
await response.aread()
|
||||
self._raise_for_status(response)
|
||||
async for line in response.aiter_lines():
|
||||
if line:
|
||||
yield line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_client: FableClient | None = None
|
||||
|
||||
|
||||
def init_client() -> FableClient:
|
||||
"""Initialise the module-level FableClient singleton (reads env vars)."""
|
||||
global _client
|
||||
_client = FableClient()
|
||||
return _client
|
||||
|
||||
|
||||
def get_client() -> FableClient:
|
||||
"""Return the singleton client; raises RuntimeError if not initialised."""
|
||||
if _client is None:
|
||||
raise RuntimeError("Call init_client() before get_client()")
|
||||
return _client
|
||||
|
||||
|
||||
def _reset_client() -> None:
|
||||
"""Reset the singleton — used by tests only."""
|
||||
global _client
|
||||
_client = None
|
||||
@@ -1,765 +0,0 @@
|
||||
"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, briefing
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_INSTRUCTIONS = """
|
||||
Fable Assistant is a self-hosted second-brain and project management system with LLM integration.
|
||||
|
||||
## Data model
|
||||
|
||||
The hierarchy is: Project → Milestone → Task/Note.
|
||||
|
||||
- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`.
|
||||
The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks.
|
||||
Do not use note tools to manipulate tasks or vice versa.
|
||||
|
||||
- **Projects** group related work. A project has a title, description, goal, status, and an
|
||||
auto-generated summary used for semantic search. Status values: `active`, `archived`.
|
||||
|
||||
- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`.
|
||||
|
||||
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
|
||||
- Status values: `todo`, `in_progress`, `done`, `cancelled`
|
||||
- Priority values: `none` (default), `low`, `medium`, `high`
|
||||
|
||||
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
|
||||
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
|
||||
|
||||
## Tags
|
||||
|
||||
Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`.
|
||||
Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags`
|
||||
leaves existing tags unchanged on updates.
|
||||
|
||||
## Integer-or-none fields
|
||||
|
||||
Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id)
|
||||
use `0` to mean "not set / no association". Pass `0` to leave the field unset.
|
||||
|
||||
## Search
|
||||
|
||||
`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find
|
||||
relevant content by meaning rather than exact keywords. Returns results ranked by cosine
|
||||
similarity with id, title, a body snippet, and tags.
|
||||
|
||||
## Chat / LLM delegation
|
||||
|
||||
`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable
|
||||
handles its own tool use, RAG context injection, and conversation history internally.
|
||||
|
||||
Use `fable_send_message` when:
|
||||
- The request is conversational or requires Fable's internal reasoning across many records
|
||||
- You want Fable's RAG to surface relevant notes automatically
|
||||
|
||||
Use the direct CRUD tools when:
|
||||
- You know exactly what to create/read/update/delete
|
||||
- You need structured data back (IDs, field values) for further processing
|
||||
- You are populating Fable programmatically from another system
|
||||
|
||||
## Task logs
|
||||
|
||||
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
|
||||
its main body. Suitable for recording work sessions, decisions, or status updates over time.
|
||||
|
||||
## RSS / Briefing
|
||||
|
||||
Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items.
|
||||
Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing.
|
||||
|
||||
## Admin logs
|
||||
|
||||
`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected.
|
||||
"""
|
||||
|
||||
mcp = FastMCP("fable", instructions=_INSTRUCTIONS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_notes(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
tag: str = "",
|
||||
search_text: str = "",
|
||||
) -> dict:
|
||||
"""List notes (non-task documents) stored in Fable.
|
||||
|
||||
Optionally filter by a single tag (plain string, no # prefix) or a keyword search
|
||||
against title and body. Results are ordered by last-updated descending.
|
||||
|
||||
Use fable_search for semantic/meaning-based lookup instead of exact keyword search.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await notes.list_notes(
|
||||
client,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
tag=tag or None,
|
||||
search=search_text or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_note(note_id: int) -> dict:
|
||||
"""Fetch the full content of a single Fable note by its ID.
|
||||
|
||||
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await notes.get_note(client, note_id=note_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_create_note(
|
||||
title: str,
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""Create a new note in Fable.
|
||||
|
||||
Args:
|
||||
title: Note title (required).
|
||||
body: Markdown content. Supports [[wikilinks]] to other notes by title.
|
||||
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
|
||||
project_id: Associate with a project (use 0 for no project / orphan note).
|
||||
|
||||
Returns the created note object including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await notes.create_note(
|
||||
client,
|
||||
title=title,
|
||||
body=body,
|
||||
tags=tags,
|
||||
project_id=project_id or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_update_note(
|
||||
note_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update an existing Fable note. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
note_id: ID of the note to update.
|
||||
title: New title, or omit to leave unchanged.
|
||||
body: New markdown body, or omit to leave unchanged.
|
||||
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
|
||||
project_id: New project association (0 = remove from project). Omit to leave unchanged.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await notes.update_note(
|
||||
client,
|
||||
note_id=note_id,
|
||||
title=title or None,
|
||||
body=body or None,
|
||||
tags=tags,
|
||||
project_id=project_id or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_delete_note(note_id: int) -> str:
|
||||
"""Permanently delete a Fable note by ID. This cannot be undone."""
|
||||
async with FableClient() as client:
|
||||
await notes.delete_note(client, note_id=note_id)
|
||||
return f"Note {note_id} deleted."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_tasks(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
status: str = "",
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""List tasks in Fable.
|
||||
|
||||
Args:
|
||||
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
|
||||
project_id: Filter to a specific project. Use 0 for no filter.
|
||||
|
||||
Results are ordered by last-updated descending.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.list_tasks(
|
||||
client,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status=status or None,
|
||||
project_id=project_id or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_task(task_id: int) -> dict:
|
||||
"""Fetch a single Fable task by ID.
|
||||
|
||||
Returns id, title, body, status, priority, tags, project_id, milestone_id,
|
||||
parent_id, parent_title, due_date, created_at, updated_at.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.get_task(client, task_id=task_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_create_task(
|
||||
title: str,
|
||||
body: str = "",
|
||||
status: str = "todo",
|
||||
priority: str = "",
|
||||
project_id: int = 0,
|
||||
milestone_id: int = 0,
|
||||
parent_id: int = 0,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a new task in Fable.
|
||||
|
||||
Args:
|
||||
title: Task title (required).
|
||||
body: Markdown description / notes for the task.
|
||||
status: Initial status — one of: todo (default), in_progress, done, cancelled.
|
||||
priority: One of: low, medium, high. Omit for no priority (defaults to "none").
|
||||
project_id: Associate with a project (0 = no project).
|
||||
milestone_id: Place within a project milestone (0 = no milestone).
|
||||
parent_id: Make this a sub-task of another task (0 = top-level).
|
||||
tags: List of plain-string tags without # prefix.
|
||||
|
||||
Returns the created task object including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.create_task(
|
||||
client,
|
||||
title=title,
|
||||
body=body,
|
||||
status=status,
|
||||
priority=priority or None,
|
||||
project_id=project_id or None,
|
||||
milestone_id=milestone_id or None,
|
||||
parent_id=parent_id or None,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_update_task(
|
||||
task_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
status: str = "",
|
||||
priority: str = "",
|
||||
project_id: int = 0,
|
||||
milestone_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update an existing Fable task. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
task_id: ID of the task to update.
|
||||
title: New title, or omit to leave unchanged.
|
||||
body: New markdown body, or omit to leave unchanged.
|
||||
status: New status — one of: todo, in_progress, done, cancelled.
|
||||
priority: New priority — one of: none, low, medium, high.
|
||||
project_id: New project (0 = remove from project). Omit to leave unchanged.
|
||||
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.update_task(
|
||||
client,
|
||||
task_id=task_id,
|
||||
title=title or None,
|
||||
body=body or None,
|
||||
status=status or None,
|
||||
priority=priority or None,
|
||||
project_id=project_id or None,
|
||||
milestone_id=milestone_id or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_add_task_log(task_id: int, content: str) -> dict:
|
||||
"""Append a timestamped progress log entry to a Fable task.
|
||||
|
||||
Use this to record work sessions, decisions, or status updates over time without
|
||||
overwriting the task's main body. Each entry is stored separately and shown
|
||||
chronologically in the task view.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.add_task_log(client, task_id=task_id, content=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_projects() -> dict:
|
||||
"""List all Fable projects for the current user.
|
||||
|
||||
Returns id, title, description, goal, status (active/archived), color,
|
||||
and a short auto-generated summary for each project.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await projects.list_projects(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_project(project_id: int) -> dict:
|
||||
"""Fetch a Fable project by ID, including its milestone summary.
|
||||
|
||||
Returns full project fields plus a milestone_summary list with each milestone's
|
||||
id, title, status, and task counts.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await projects.get_project(client, project_id=project_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_create_project(
|
||||
title: str,
|
||||
description: str = "",
|
||||
goal: str = "",
|
||||
status: str = "active",
|
||||
color: str = "",
|
||||
) -> dict:
|
||||
"""Create a new project in Fable.
|
||||
|
||||
Args:
|
||||
title: Project name (required).
|
||||
description: Short summary of what the project is.
|
||||
goal: The desired outcome or definition of done for the project.
|
||||
status: active (default) or archived.
|
||||
color: Optional hex colour for the project card (e.g. "#6366f1").
|
||||
|
||||
Returns the created project object including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await projects.create_project(
|
||||
client,
|
||||
title=title,
|
||||
description=description,
|
||||
goal=goal or None,
|
||||
status=status,
|
||||
color=color or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_update_project(
|
||||
project_id: int,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
goal: str = "",
|
||||
status: str = "",
|
||||
color: str = "",
|
||||
) -> dict:
|
||||
"""Update an existing Fable project. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
project_id: ID of the project to update.
|
||||
title: New title, or omit to leave unchanged.
|
||||
description: New description, or omit to leave unchanged.
|
||||
goal: New goal/definition-of-done, or omit to leave unchanged.
|
||||
status: New status — active or archived.
|
||||
color: New hex colour, or omit to leave unchanged.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await projects.update_project(
|
||||
client,
|
||||
project_id=project_id,
|
||||
title=title or None,
|
||||
description=description or None,
|
||||
goal=goal or None,
|
||||
status=status or None,
|
||||
color=color or None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Milestones
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_milestones(project_id: int) -> dict:
|
||||
"""List milestones for a Fable project, ordered by order_index.
|
||||
|
||||
Returns id, title, description, status (active/done), order_index, and task counts.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await milestones.list_milestones(client, project_id=project_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_create_milestone(
|
||||
project_id: int,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: str = "active",
|
||||
) -> dict:
|
||||
"""Create a milestone within a Fable project.
|
||||
|
||||
Args:
|
||||
project_id: The project this milestone belongs to (required).
|
||||
title: Milestone name (required).
|
||||
description: Optional description of what this milestone covers.
|
||||
status: active (default) or done.
|
||||
|
||||
Returns the created milestone including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await milestones.create_milestone(
|
||||
client,
|
||||
project_id=project_id,
|
||||
title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_update_milestone(
|
||||
project_id: int,
|
||||
milestone_id: int,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
status: str = "",
|
||||
order_index: int = -1,
|
||||
) -> dict:
|
||||
"""Update a Fable milestone. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
project_id: Project the milestone belongs to.
|
||||
milestone_id: ID of the milestone to update.
|
||||
title: New title, or omit to leave unchanged.
|
||||
description: New description, or omit to leave unchanged.
|
||||
status: New status — active or done.
|
||||
order_index: New display position (0-based). Use -1 to leave unchanged.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await milestones.update_milestone(
|
||||
client,
|
||||
project_id=project_id,
|
||||
milestone_id=milestone_id,
|
||||
title=title or None,
|
||||
description=description or None,
|
||||
status=status or None,
|
||||
order_index=order_index if order_index >= 0 else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_search(
|
||||
q: str,
|
||||
content_type: str = "all",
|
||||
limit: int = 10,
|
||||
) -> dict:
|
||||
"""Semantic search over Fable notes and tasks using embedding similarity.
|
||||
|
||||
Finds content by meaning rather than exact keywords. Use this to discover
|
||||
relevant records when you don't know the exact title or tags.
|
||||
|
||||
Args:
|
||||
q: Natural-language query string.
|
||||
content_type: "note", "task", or "all" (default).
|
||||
limit: Maximum number of results (default 10).
|
||||
|
||||
Returns results ordered by cosine similarity, each with id, title, body snippet, and tags.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await search.search(client, q=q, content_type=content_type, limit=limit)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
|
||||
"""List chat conversations stored in Fable, ordered by last activity.
|
||||
|
||||
Returns id, title, message_count, created_at, updated_at for each conversation.
|
||||
Use the id with fable_send_message to continue a specific conversation.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await chat.list_conversations(client, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_send_message(
|
||||
message: str,
|
||||
conversation_id: str = "",
|
||||
think: bool = False,
|
||||
) -> dict:
|
||||
"""Send a natural-language message to Fable's built-in LLM and receive the full response.
|
||||
|
||||
Fable handles tool use, RAG context injection, and conversation history internally.
|
||||
The LLM can create/update notes and tasks, search, manage projects, and more — all
|
||||
driven by natural language without you needing to call individual tools.
|
||||
|
||||
Use this when:
|
||||
- The request is conversational or exploratory
|
||||
- You want Fable's RAG to automatically surface relevant notes as context
|
||||
- The task is complex enough to benefit from Fable's internal reasoning
|
||||
|
||||
Use the direct CRUD tools (fable_create_note, etc.) instead when you need
|
||||
structured data back or are performing bulk/programmatic operations.
|
||||
|
||||
Args:
|
||||
message: The user message to send.
|
||||
conversation_id: Continue an existing conversation by passing its id.
|
||||
Omit to start a new conversation.
|
||||
think: Enable extended reasoning mode for complex multi-step requests.
|
||||
|
||||
Returns conversation_id (for follow-up messages), the assistant response text,
|
||||
and a list of any tool_call events that fired during generation.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await chat.send_message(
|
||||
client,
|
||||
message=message,
|
||||
conversation_id=conversation_id or None,
|
||||
think=think,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin / observability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_app_logs(
|
||||
category: str = "error",
|
||||
limit: int = 20,
|
||||
search: str = "",
|
||||
) -> dict:
|
||||
"""Fetch Fable application logs. Requires an admin-scoped API key.
|
||||
|
||||
Args:
|
||||
category: Log category — "error" (default), "audit", "usage", or "generation".
|
||||
limit: Maximum number of log entries to return.
|
||||
search: Optional keyword filter matched against action, endpoint, username, details.
|
||||
|
||||
Returns a list of log entries ordered by most recent first.
|
||||
Regular user API keys will receive a 403 — only admin keys are accepted.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await admin.get_app_logs(
|
||||
client,
|
||||
category=category,
|
||||
limit=limit,
|
||||
search=search or None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Briefing / RSS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_rss_feeds() -> dict:
|
||||
"""List all RSS/Atom feeds configured in Fable for the current user.
|
||||
|
||||
Returns id, title, url, category, and last_fetched_at for each feed.
|
||||
These feeds are summarised in the user's daily briefing.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.list_rss_feeds(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_add_rss_feed(
|
||||
url: str,
|
||||
title: str = "",
|
||||
category: str = "",
|
||||
) -> dict:
|
||||
"""Add an RSS or Atom feed to Fable's daily briefing.
|
||||
|
||||
Args:
|
||||
url: The RSS/Atom feed URL (required).
|
||||
title: Optional display name. If omitted, auto-populated from feed metadata.
|
||||
category: Optional category label to group feeds (e.g. "news", "tech", "finance").
|
||||
|
||||
Returns the created feed object including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.add_rss_feed(
|
||||
client,
|
||||
url=url,
|
||||
title=title or None,
|
||||
category=category or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_remove_rss_feed(feed_id: int) -> dict:
|
||||
"""Remove an RSS feed from Fable by its ID."""
|
||||
async with FableClient() as client:
|
||||
return await briefing.remove_rss_feed(client, feed_id=feed_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Briefing introspection & control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_briefings() -> dict:
|
||||
"""List the user's briefing conversations, newest first.
|
||||
|
||||
Each briefing has an associated date and conversation id. Use
|
||||
``fable_get_briefing_messages`` or ``fable_get_conversation`` with
|
||||
the id to pull the actual briefing text and tool-call receipts.
|
||||
|
||||
Returns a dict with a ``conversations`` list.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.list_briefing_conversations(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_today_briefing() -> dict:
|
||||
"""Fetch today's briefing conversation, creating it if needed.
|
||||
|
||||
Returns the full conversation object including all messages — the
|
||||
scheduled briefing assistant turns, any tool calls the agentic path
|
||||
made, and any chat replies the user has sent in the briefing thread.
|
||||
|
||||
Use this to inspect what a briefing actually said (and what tool
|
||||
results grounded it) without having to query by id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.get_today_briefing(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_briefing_messages(conversation_id: int) -> dict:
|
||||
"""Fetch all messages for a specific briefing conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The briefing conversation id from fable_list_briefings.
|
||||
|
||||
Returns a dict with a ``messages`` list. Each message includes
|
||||
role, content, tool_calls (with results), and metadata — the
|
||||
metadata carries ``briefing_slot`` tags on agentic briefing turns.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.get_briefing_messages(
|
||||
client, conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_trigger_briefing(slot: str = "compilation") -> dict:
|
||||
"""Manually run a briefing slot for the current user.
|
||||
|
||||
Fires the same data refresh the scheduler does (RSS, weather),
|
||||
runs the agentic briefing pipeline, and writes the result into
|
||||
today's briefing conversation. Use this to test prompt changes
|
||||
without waiting for the next scheduled slot.
|
||||
|
||||
Args:
|
||||
slot: One of ``compilation`` (full morning, default), ``morning``,
|
||||
``midday``, or ``afternoon``.
|
||||
|
||||
Returns a dict with ``conversation_id``, ``message_id``, and ``slot``.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.trigger_briefing(client, slot=slot)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_reset_today_briefing(run_compilation: bool = True) -> dict:
|
||||
"""Wipe today's briefing and (optionally) regenerate from scratch.
|
||||
|
||||
Deletes every message in today's briefing conversation — the
|
||||
conversation row itself is kept so its id stays stable for any
|
||||
open UI sessions. If ``run_compilation`` is True (the default),
|
||||
immediately fires the compilation slot afterward so a fresh
|
||||
briefing lands in place of the deleted content.
|
||||
|
||||
Use this when iterating on briefing prompts or tools and you want
|
||||
to start from a clean slate rather than append another slot update
|
||||
on top of stale output.
|
||||
|
||||
Args:
|
||||
run_compilation: When True, fire ``POST /api/briefing/trigger``
|
||||
for the ``compilation`` slot immediately after wiping.
|
||||
Set False to only wipe without regenerating.
|
||||
|
||||
Returns a dict with ``reset`` (the delete result: deleted count +
|
||||
conversation id) and ``triggered`` (the new message payload, or
|
||||
null if regeneration was skipped).
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.reset_today_briefing(
|
||||
client, run_compilation=run_compilation,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic conversation access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_conversation(conversation_id: int) -> dict:
|
||||
"""Fetch any conversation (chat or briefing) with its full message list.
|
||||
|
||||
Returns conversation metadata plus an ordered ``messages`` array.
|
||||
Each message includes role, content, tool_calls (with results),
|
||||
context_note_id, and msg_metadata. Tool calls are in the stored
|
||||
flat format: ``[{"function": name, "arguments": {...}, "result": {...}}]``.
|
||||
|
||||
Useful for debugging agentic briefings, inspecting chat history,
|
||||
or verifying that a tool actually ran with the expected arguments.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.get_conversation(
|
||||
client, conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Validate env vars at startup — raises ValueError with a clear message if missing.
|
||||
FableClient()
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Admin tools: application log access (requires admin API key)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def get_app_logs(
|
||||
client: FableClient,
|
||||
category: str = "error",
|
||||
limit: int = 20,
|
||||
search: str | None = None,
|
||||
) -> dict:
|
||||
"""Fetch application logs from Fable. Requires an admin-scoped API key.
|
||||
|
||||
category: "error" | "audit" | "usage" | "generation" (default: "error")
|
||||
"""
|
||||
params: dict = {"category": category, "limit": limit}
|
||||
if search:
|
||||
params["search"] = search
|
||||
return await client.get("/api/admin/logs", params=params)
|
||||
@@ -1,98 +0,0 @@
|
||||
"""MCP tools for Fable briefings and RSS feed management."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
# ── RSS feeds ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_rss_feeds(client: FableClient) -> dict[str, Any]:
|
||||
"""List the user's RSS feeds."""
|
||||
return await client.get("/api/briefing/feeds")
|
||||
|
||||
|
||||
async def add_rss_feed(
|
||||
client: FableClient,
|
||||
*,
|
||||
url: str,
|
||||
title: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a new RSS feed. Title is optional — auto-populated from feed metadata."""
|
||||
payload: dict[str, Any] = {"url": url}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
if category:
|
||||
payload["category"] = category
|
||||
return await client.post("/api/briefing/feeds", json=payload)
|
||||
|
||||
|
||||
async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]:
|
||||
"""Remove an RSS feed by ID."""
|
||||
return await client.delete(f"/api/briefing/feeds/{feed_id}")
|
||||
|
||||
|
||||
# ── Briefings ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_briefing_conversations(client: FableClient) -> dict[str, Any]:
|
||||
"""List the user's briefing conversations, newest first."""
|
||||
return await client.get("/api/briefing/conversations")
|
||||
|
||||
|
||||
async def get_today_briefing(client: FableClient) -> dict[str, Any]:
|
||||
"""Fetch today's briefing conversation with all messages."""
|
||||
return await client.get("/api/briefing/conversations/today")
|
||||
|
||||
|
||||
async def get_briefing_messages(
|
||||
client: FableClient,
|
||||
*,
|
||||
conversation_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch messages for a specific briefing conversation."""
|
||||
return await client.get(f"/api/briefing/conversations/{conversation_id}/messages")
|
||||
|
||||
|
||||
async def trigger_briefing(
|
||||
client: FableClient,
|
||||
*,
|
||||
slot: str = "compilation",
|
||||
) -> dict[str, Any]:
|
||||
"""Manually trigger a briefing slot — fires data refresh and runs the pipeline.
|
||||
|
||||
Slot is one of: compilation (full morning), morning, midday, afternoon.
|
||||
"""
|
||||
return await client.post("/api/briefing/trigger", json={"slot": slot})
|
||||
|
||||
|
||||
async def reset_today_briefing(
|
||||
client: FableClient,
|
||||
*,
|
||||
run_compilation: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Delete all messages in today's briefing and optionally regenerate.
|
||||
|
||||
Wipes the messages in today's briefing conversation (keeping the
|
||||
conversation row), then, if ``run_compilation`` is True, fires the
|
||||
compilation slot so a fresh briefing lands in its place.
|
||||
"""
|
||||
reset = await client.post("/api/briefing/reset-today")
|
||||
if not run_compilation:
|
||||
return {"reset": reset, "triggered": None}
|
||||
triggered = await client.post(
|
||||
"/api/briefing/trigger", json={"slot": "compilation"}
|
||||
)
|
||||
return {"reset": reset, "triggered": triggered}
|
||||
|
||||
|
||||
# ── Generic conversations ───────────────────────────────────────────────────
|
||||
|
||||
async def get_conversation(
|
||||
client: FableClient,
|
||||
*,
|
||||
conversation_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch any conversation (chat or briefing) with all messages and tool calls."""
|
||||
return await client.get(f"/api/chat/conversations/{conversation_id}")
|
||||
@@ -1,95 +0,0 @@
|
||||
"""MCP tools for Fable chat — create conversations and stream responses."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient, FableAPIError
|
||||
|
||||
|
||||
async def list_conversations(
|
||||
client: FableClient,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""List MCP chat conversations."""
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset, "type": "mcp"}
|
||||
return await client.get("/api/chat/conversations", params=params)
|
||||
|
||||
|
||||
async def send_message(
|
||||
client: FableClient,
|
||||
*,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
think: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a message to Fable and return the full assistant response.
|
||||
|
||||
Creates a new MCP conversation if conversation_id is None.
|
||||
Posts the user message to start generation, then streams the SSE buffer.
|
||||
|
||||
SSE event format:
|
||||
id: <N>
|
||||
event: <type> # chunk | done | tool_call | status | ...
|
||||
data: <json>
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- "conversation_id": str
|
||||
- "response": str (full assistant message)
|
||||
- "tool_calls": list of any tool_call events observed
|
||||
"""
|
||||
if conversation_id is None:
|
||||
conv = await client.post(
|
||||
"/api/chat/conversations",
|
||||
json={"conversation_type": "mcp"},
|
||||
)
|
||||
conversation_id = str(conv["id"])
|
||||
|
||||
# Start generation
|
||||
await client.post(
|
||||
f"/api/chat/conversations/{conversation_id}/messages",
|
||||
json={"content": message, "think": think},
|
||||
)
|
||||
|
||||
tokens: list[str] = []
|
||||
tool_calls: list[Any] = []
|
||||
|
||||
stream_path = f"/api/chat/conversations/{conversation_id}/generation/stream"
|
||||
|
||||
# Retry connecting to the stream briefly — the background task may not have
|
||||
# created the generation buffer by the time we issue the GET.
|
||||
for attempt in range(10):
|
||||
try:
|
||||
event_type: str | None = None
|
||||
async for raw_line in client.stream_get(stream_path):
|
||||
if raw_line.startswith("event: "):
|
||||
event_type = raw_line[len("event: "):].strip()
|
||||
elif raw_line.startswith("data: "):
|
||||
payload_str = raw_line[len("data: "):]
|
||||
try:
|
||||
data = json.loads(payload_str)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event_type == "chunk":
|
||||
tokens.append(data.get("chunk", ""))
|
||||
elif event_type == "tool_call":
|
||||
tool_calls.append(data.get("tool_call", data))
|
||||
elif event_type == "done":
|
||||
break
|
||||
event_type = None # reset after consuming data line
|
||||
break # stream completed successfully
|
||||
except FableAPIError as exc:
|
||||
if exc.status_code == 404 and attempt < 9:
|
||||
await asyncio.sleep(0.3)
|
||||
continue
|
||||
raise
|
||||
|
||||
return {
|
||||
"conversation_id": conversation_id,
|
||||
"response": "".join(tokens),
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"""MCP tools for Fable milestones."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_milestones(client: FableClient, *, project_id: int) -> dict[str, Any]:
|
||||
"""List milestones for a project."""
|
||||
return await client.get(f"/api/projects/{project_id}/milestones")
|
||||
|
||||
|
||||
async def create_milestone(
|
||||
client: FableClient,
|
||||
*,
|
||||
project_id: int,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: str = "active",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a milestone within a project."""
|
||||
payload: dict[str, Any] = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": status,
|
||||
}
|
||||
return await client.post(f"/api/projects/{project_id}/milestones", json=payload)
|
||||
|
||||
|
||||
async def update_milestone(
|
||||
client: FableClient,
|
||||
*,
|
||||
project_id: int,
|
||||
milestone_id: int,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
status: str | None = None,
|
||||
order_index: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing milestone."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
if order_index is not None:
|
||||
payload["order_index"] = order_index
|
||||
return await client.patch(
|
||||
f"/api/projects/{project_id}/milestones/{milestone_id}", json=payload
|
||||
)
|
||||
@@ -1,72 +0,0 @@
|
||||
"""MCP tools for Fable notes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_notes(
|
||||
client: FableClient,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
tag: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List notes with optional filtering."""
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||
if tag:
|
||||
params["tag"] = tag
|
||||
if search:
|
||||
params["search"] = search
|
||||
return await client.get("/api/notes", params=params)
|
||||
|
||||
|
||||
async def get_note(client: FableClient, *, note_id: int) -> dict[str, Any]:
|
||||
"""Fetch a single note by ID."""
|
||||
return await client.get(f"/api/notes/{note_id}")
|
||||
|
||||
|
||||
async def create_note(
|
||||
client: FableClient,
|
||||
*,
|
||||
title: str,
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new note."""
|
||||
payload: dict[str, Any] = {"title": title, "body": body}
|
||||
if tags is not None:
|
||||
payload["tags"] = tags
|
||||
if project_id is not None:
|
||||
payload["project_id"] = project_id
|
||||
return await client.post("/api/notes", json=payload)
|
||||
|
||||
|
||||
async def update_note(
|
||||
client: FableClient,
|
||||
*,
|
||||
note_id: int,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing note."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
if tags is not None:
|
||||
payload["tags"] = tags
|
||||
if project_id is not None:
|
||||
payload["project_id"] = project_id
|
||||
return await client.patch(f"/api/notes/{note_id}", json=payload)
|
||||
|
||||
|
||||
async def delete_note(client: FableClient, *, note_id: int) -> None:
|
||||
"""Delete a note by ID."""
|
||||
await client.delete(f"/api/notes/{note_id}")
|
||||
@@ -1,59 +0,0 @@
|
||||
"""MCP tools for Fable projects."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_projects(client: FableClient) -> dict[str, Any]:
|
||||
"""List all projects for the authenticated user."""
|
||||
return await client.get("/api/projects")
|
||||
|
||||
|
||||
async def get_project(client: FableClient, *, project_id: int) -> dict[str, Any]:
|
||||
"""Fetch a project by ID (includes milestone summary)."""
|
||||
return await client.get(f"/api/projects/{project_id}")
|
||||
|
||||
|
||||
async def create_project(
|
||||
client: FableClient,
|
||||
*,
|
||||
title: str,
|
||||
description: str = "",
|
||||
goal: str | None = None,
|
||||
status: str = "active",
|
||||
color: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new project."""
|
||||
payload: dict[str, Any] = {"title": title, "description": description, "status": status}
|
||||
if goal is not None:
|
||||
payload["goal"] = goal
|
||||
if color is not None:
|
||||
payload["color"] = color
|
||||
return await client.post("/api/projects", json=payload)
|
||||
|
||||
|
||||
async def update_project(
|
||||
client: FableClient,
|
||||
*,
|
||||
project_id: int,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
goal: str | None = None,
|
||||
status: str | None = None,
|
||||
color: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing project."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if goal is not None:
|
||||
payload["goal"] = goal
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
if color is not None:
|
||||
payload["color"] = color
|
||||
return await client.patch(f"/api/projects/{project_id}", json=payload)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""MCP tool for semantic search across Fable notes and tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def search(
|
||||
client: FableClient,
|
||||
*,
|
||||
q: str,
|
||||
content_type: str = "all",
|
||||
limit: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""Semantic search over notes and/or tasks.
|
||||
|
||||
Args:
|
||||
q: Search query string.
|
||||
content_type: One of "note", "task", or "all" (default).
|
||||
limit: Maximum number of results to return.
|
||||
|
||||
Returns:
|
||||
Dict with "results" list and "total" count.
|
||||
Each result has: id, title, body, is_task, tags, similarity.
|
||||
"""
|
||||
params: dict[str, Any] = {"q": q, "limit": limit}
|
||||
if content_type != "all":
|
||||
params["content_type"] = content_type
|
||||
return await client.get("/api/search", params=params)
|
||||
@@ -1,93 +0,0 @@
|
||||
"""MCP tools for Fable tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
client: FableClient,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
status: str | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List tasks with optional filtering."""
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if project_id is not None:
|
||||
params["project_id"] = project_id
|
||||
return await client.get("/api/tasks", params=params)
|
||||
|
||||
|
||||
async def get_task(client: FableClient, *, task_id: int) -> dict[str, Any]:
|
||||
"""Fetch a single task by ID (includes parent_title)."""
|
||||
return await client.get(f"/api/tasks/{task_id}")
|
||||
|
||||
|
||||
async def create_task(
|
||||
client: FableClient,
|
||||
*,
|
||||
title: str,
|
||||
body: str = "",
|
||||
status: str = "todo",
|
||||
priority: str | None = None,
|
||||
project_id: int | None = None,
|
||||
milestone_id: int | None = None,
|
||||
parent_id: int | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new task."""
|
||||
payload: dict[str, Any] = {"title": title, "body": body, "status": status}
|
||||
if priority is not None:
|
||||
payload["priority"] = priority
|
||||
if project_id is not None:
|
||||
payload["project_id"] = project_id
|
||||
if milestone_id is not None:
|
||||
payload["milestone_id"] = milestone_id
|
||||
if parent_id is not None:
|
||||
payload["parent_id"] = parent_id
|
||||
if tags is not None:
|
||||
payload["tags"] = tags
|
||||
return await client.post("/api/tasks", json=payload)
|
||||
|
||||
|
||||
async def update_task(
|
||||
client: FableClient,
|
||||
*,
|
||||
task_id: int,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
project_id: int | None = None,
|
||||
milestone_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing task."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
if priority is not None:
|
||||
payload["priority"] = priority
|
||||
if project_id is not None:
|
||||
payload["project_id"] = project_id
|
||||
if milestone_id is not None:
|
||||
payload["milestone_id"] = milestone_id
|
||||
return await client.patch(f"/api/tasks/{task_id}", json=payload)
|
||||
|
||||
|
||||
async def add_task_log(
|
||||
client: FableClient,
|
||||
*,
|
||||
task_id: int,
|
||||
content: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a log entry to a task."""
|
||||
return await client.post(f"/api/tasks/{task_id}/logs", json={"content": content})
|
||||
@@ -1,24 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.6"
|
||||
description = "MCP server for Fabled Assistant"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.0",
|
||||
"httpx>=0.27",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
fable-mcp = "fable_mcp.server:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Shared pytest fixtures for fable-mcp tests."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""A mock FableClient that returns empty responses by default."""
|
||||
client = AsyncMock()
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
return client
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Tests for FableClient."""
|
||||
import os
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from unittest.mock import patch
|
||||
|
||||
from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singleton():
|
||||
"""Reset the module-level client singleton between tests."""
|
||||
_reset_client()
|
||||
yield
|
||||
_reset_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_env(monkeypatch):
|
||||
monkeypatch.setenv("FABLE_URL", "http://fable.test")
|
||||
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
|
||||
|
||||
|
||||
class TestFableClientInit:
|
||||
def test_missing_fable_url_raises(self, monkeypatch):
|
||||
monkeypatch.delenv("FABLE_URL", raising=False)
|
||||
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
|
||||
with pytest.raises(ValueError, match="FABLE_URL"):
|
||||
FableClient()
|
||||
|
||||
def test_missing_api_key_raises(self, monkeypatch):
|
||||
monkeypatch.setenv("FABLE_URL", "http://fable.test")
|
||||
monkeypatch.delenv("FABLE_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="FABLE_API_KEY"):
|
||||
FableClient()
|
||||
|
||||
def test_trailing_slash_stripped(self, base_env):
|
||||
with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}):
|
||||
client = FableClient()
|
||||
assert client.base_url == "http://fable.test"
|
||||
|
||||
def test_auth_header_set(self, base_env):
|
||||
client = FableClient()
|
||||
assert client._headers["Authorization"] == "Bearer fmcp_testkey"
|
||||
|
||||
|
||||
class TestFableClientHTTP:
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_json(self, base_env):
|
||||
respx.get("http://fable.test/api/notes").mock(
|
||||
return_value=httpx.Response(200, json={"notes": []})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
data = await client.get("/api/notes")
|
||||
assert data == {"notes": []}
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_raises_fable_api_error(self, base_env):
|
||||
respx.get("http://fable.test/api/notes").mock(
|
||||
return_value=httpx.Response(401, json={"error": "Unauthorized"})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
with pytest.raises(FableAPIError) as exc_info:
|
||||
await client.get("/api/notes")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_sends_json(self, base_env):
|
||||
respx.post("http://fable.test/api/notes").mock(
|
||||
return_value=httpx.Response(201, json={"id": 1, "title": "Test"})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
data = await client.post("/api/notes", json={"title": "Test"})
|
||||
assert data["id"] == 1
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_sends_json(self, base_env):
|
||||
respx.patch("http://fable.test/api/notes/1").mock(
|
||||
return_value=httpx.Response(200, json={"id": 1, "title": "Updated"})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
data = await client.patch("/api/notes/1", json={"title": "Updated"})
|
||||
assert data["title"] == "Updated"
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_returns_none_on_204(self, base_env):
|
||||
respx.delete("http://fable.test/api/notes/1").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with FableClient() as client:
|
||||
result = await client.delete("/api/notes/1")
|
||||
assert result is None
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fable_api_error_includes_message(self, base_env):
|
||||
respx.get("http://fable.test/api/notes").mock(
|
||||
return_value=httpx.Response(404, json={"error": "Not found"})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
with pytest.raises(FableAPIError) as exc_info:
|
||||
await client.get("/api/notes")
|
||||
assert "Not found" in str(exc_info.value)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
def test_get_client_before_init_raises(self):
|
||||
with pytest.raises(RuntimeError, match="init_client"):
|
||||
get_client()
|
||||
|
||||
def test_init_client_returns_instance(self, base_env):
|
||||
client = init_client()
|
||||
assert isinstance(client, FableClient)
|
||||
|
||||
def test_get_client_after_init_returns_same(self, base_env):
|
||||
init_client()
|
||||
c1 = get_client()
|
||||
c2 = get_client()
|
||||
assert c1 is c2
|
||||
|
||||
|
||||
class TestStreamGet:
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_get_yields_lines(self, base_env):
|
||||
sse_body = b"data: line1\n\ndata: line2\n\n"
|
||||
respx.get("http://fable.test/api/stream").mock(
|
||||
return_value=httpx.Response(200, content=sse_body)
|
||||
)
|
||||
lines = []
|
||||
async with FableClient() as client:
|
||||
async for line in client.stream_get("/api/stream"):
|
||||
lines.append(line)
|
||||
assert "data: line1" in lines
|
||||
assert "data: line2" in lines
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Tests for chat MCP tools."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_creates_conversation_and_streams(client):
|
||||
from fable_mcp.tools.chat import send_message
|
||||
|
||||
client.post = AsyncMock(return_value={"id": "conv-1"})
|
||||
|
||||
async def fake_stream(path, **kwargs):
|
||||
for line in [
|
||||
'data: {"type": "token", "content": "Hello"}',
|
||||
'data: {"type": "token", "content": " world"}',
|
||||
'data: {"type": "done"}',
|
||||
]:
|
||||
yield line
|
||||
|
||||
client.stream_get = fake_stream
|
||||
|
||||
result = await send_message(client, message="Hi", conversation_id=None)
|
||||
assert result["conversation_id"] == "conv-1"
|
||||
assert "Hello world" in result["response"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_reuses_existing_conversation(client):
|
||||
from fable_mcp.tools.chat import send_message
|
||||
|
||||
async def fake_stream(path, **kwargs):
|
||||
for line in [
|
||||
'data: {"type": "token", "content": "Reply"}',
|
||||
'data: {"type": "done"}',
|
||||
]:
|
||||
yield line
|
||||
|
||||
client.stream_get = fake_stream
|
||||
|
||||
result = await send_message(client, message="Hi", conversation_id="existing-conv")
|
||||
# Should not call post to create a conversation
|
||||
client.post.assert_not_called()
|
||||
assert result["conversation_id"] == "existing-conv"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_conversations_calls_get(client):
|
||||
from fable_mcp.tools.chat import list_conversations
|
||||
client.get = AsyncMock(return_value={"conversations": []})
|
||||
await list_conversations(client)
|
||||
client.get.assert_called_once()
|
||||
assert "/api/chat/conversations" in client.get.call_args[0][0]
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Tests for milestones MCP tools."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_milestones_calls_correct_endpoint(client):
|
||||
from fable_mcp.tools.milestones import list_milestones
|
||||
client.get = AsyncMock(return_value={"milestones": []})
|
||||
await list_milestones(client, project_id=3)
|
||||
client.get.assert_called_once_with("/api/projects/3/milestones")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_milestone_posts_to_project_endpoint(client):
|
||||
from fable_mcp.tools.milestones import create_milestone
|
||||
client.post = AsyncMock(return_value={"id": 10, "title": "M1"})
|
||||
await create_milestone(client, project_id=3, title="M1")
|
||||
path = client.post.call_args[0][0]
|
||||
assert path == "/api/projects/3/milestones"
|
||||
assert client.post.call_args[1]["json"]["title"] == "M1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_milestone_patches(client):
|
||||
from fable_mcp.tools.milestones import update_milestone
|
||||
client.patch = AsyncMock(return_value={"id": 10, "status": "completed"})
|
||||
await update_milestone(client, project_id=3, milestone_id=10, status="completed")
|
||||
path = client.patch.call_args[0][0]
|
||||
assert "/api/projects/3/milestones/10" in path
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Tests for notes MCP tools."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notes_calls_get(client):
|
||||
from fable_mcp.tools.notes import list_notes
|
||||
client.get = AsyncMock(return_value={"notes": [], "total": 0})
|
||||
result = await list_notes(client, limit=10)
|
||||
client.get.assert_called_once()
|
||||
call_args = client.get.call_args
|
||||
assert "/api/notes" in call_args[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_note_calls_correct_endpoint(client):
|
||||
from fable_mcp.tools.notes import get_note
|
||||
client.get = AsyncMock(return_value={"id": 42, "title": "Hello"})
|
||||
result = await get_note(client, note_id=42)
|
||||
client.get.assert_called_once_with("/api/notes/42")
|
||||
assert result["id"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_posts_payload(client):
|
||||
from fable_mcp.tools.notes import create_note
|
||||
client.post = AsyncMock(return_value={"id": 1, "title": "My Note"})
|
||||
result = await create_note(client, title="My Note", body="Content")
|
||||
client.post.assert_called_once()
|
||||
call_kwargs = client.post.call_args[1]
|
||||
assert call_kwargs["json"]["title"] == "My Note"
|
||||
assert call_kwargs["json"]["body"] == "Content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_note_patches_payload(client):
|
||||
from fable_mcp.tools.notes import update_note
|
||||
client.patch = AsyncMock(return_value={"id": 1, "title": "Updated"})
|
||||
result = await update_note(client, note_id=1, title="Updated")
|
||||
client.patch.assert_called_once()
|
||||
path = client.patch.call_args[0][0]
|
||||
assert "/api/notes/1" in path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_note_calls_delete(client):
|
||||
from fable_mcp.tools.notes import delete_note
|
||||
client.delete = AsyncMock(return_value=None)
|
||||
await delete_note(client, note_id=5)
|
||||
client.delete.assert_called_once_with("/api/notes/5")
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Tests for projects MCP tools."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_calls_get(client):
|
||||
from fable_mcp.tools.projects import list_projects
|
||||
client.get = AsyncMock(return_value={"projects": []})
|
||||
await list_projects(client)
|
||||
client.get.assert_called_once_with("/api/projects")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_calls_correct_endpoint(client):
|
||||
from fable_mcp.tools.projects import get_project
|
||||
client.get = AsyncMock(return_value={"id": 2, "title": "Proj"})
|
||||
result = await get_project(client, project_id=2)
|
||||
client.get.assert_called_once_with("/api/projects/2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_posts_payload(client):
|
||||
from fable_mcp.tools.projects import create_project
|
||||
client.post = AsyncMock(return_value={"id": 4, "title": "New"})
|
||||
await create_project(client, title="New", description="Desc")
|
||||
call_kwargs = client.post.call_args[1]
|
||||
assert call_kwargs["json"]["title"] == "New"
|
||||
assert call_kwargs["json"]["description"] == "Desc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_patches(client):
|
||||
from fable_mcp.tools.projects import update_project
|
||||
client.patch = AsyncMock(return_value={"id": 4, "status": "active"})
|
||||
await update_project(client, project_id=4, status="active")
|
||||
assert "/api/projects/4" in client.patch.call_args[0][0]
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Tests for tasks MCP tools."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_calls_get(client):
|
||||
from fable_mcp.tools.tasks import list_tasks
|
||||
client.get = AsyncMock(return_value={"tasks": [], "total": 0})
|
||||
await list_tasks(client)
|
||||
client.get.assert_called_once()
|
||||
assert "/api/tasks" in client.get.call_args[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task_calls_correct_endpoint(client):
|
||||
from fable_mcp.tools.tasks import get_task
|
||||
client.get = AsyncMock(return_value={"id": 7, "title": "Fix bug"})
|
||||
result = await get_task(client, task_id=7)
|
||||
client.get.assert_called_once_with("/api/tasks/7")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_posts_payload(client):
|
||||
from fable_mcp.tools.tasks import create_task
|
||||
client.post = AsyncMock(return_value={"id": 3, "title": "New task"})
|
||||
await create_task(client, title="New task")
|
||||
call_kwargs = client.post.call_args[1]
|
||||
assert call_kwargs["json"]["title"] == "New task"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_patches(client):
|
||||
from fable_mcp.tools.tasks import update_task
|
||||
client.patch = AsyncMock(return_value={"id": 3, "status": "done"})
|
||||
await update_task(client, task_id=3, status="done")
|
||||
client.patch.assert_called_once()
|
||||
assert "/api/tasks/3" in client.patch.call_args[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_task_log_posts_to_logs_endpoint(client):
|
||||
from fable_mcp.tools.tasks import add_task_log
|
||||
client.post = AsyncMock(return_value={"id": 1, "content": "progress"})
|
||||
await add_task_log(client, task_id=9, content="progress")
|
||||
client.post.assert_called_once()
|
||||
path = client.post.call_args[0][0]
|
||||
assert path == "/api/tasks/9/logs"
|
||||
assert client.post.call_args[1]["json"]["content"] == "progress"
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Fabled">
|
||||
<title>Fabled Assistant</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Scribe">
|
||||
<title>Fabled Scribe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+807
@@ -24,6 +24,7 @@
|
||||
"@tiptap/vue-3": "^3.0.0",
|
||||
"d3": "^7",
|
||||
"dompurify": "^3.1.0",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"marked": "^17.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "3.5.30",
|
||||
@@ -91,6 +92,278 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"cpu": [
|
||||
@@ -106,6 +379,159 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"license": "MIT",
|
||||
@@ -284,6 +710,294 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
"cpu": [
|
||||
@@ -296,6 +1010,90 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.20.1",
|
||||
"license": "MIT",
|
||||
@@ -1908,6 +2706,15 @@
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.469.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.469.0.tgz",
|
||||
"integrity": "sha512-EjOap+vY3xEzCMrnaccDHO4BH3k3Lr+sOyvzRQCaayYxkxKla0w6Jr4h3cHAzA4vMSp63Dcy7vDiGeCPcCY+Gg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@tiptap/vue-3": "^3.0.0",
|
||||
"d3": "^7",
|
||||
"dompurify": "^3.1.0",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"marked": "^17.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "3.5.30",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Fabled Assistant",
|
||||
"short_name": "Fabled",
|
||||
"name": "Fabled Scribe",
|
||||
"short_name": "Scribe",
|
||||
"description": "Your self-hosted second brain with AI assistance",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
|
||||
+8
-42
@@ -1,45 +1,11 @@
|
||||
// Service Worker for push notifications and PWA support
|
||||
self.addEventListener('push', event => {
|
||||
if (!event.data) return;
|
||||
const data = event.data.json();
|
||||
const notifUrl = data.url || '/';
|
||||
// Service Worker — PWA installability shell only.
|
||||
//
|
||||
// The push and notificationclick listeners were removed alongside the chat
|
||||
// generation pipeline (they only fired when the internal LLM finished a
|
||||
// response). The empty fetch handler is preserved as the installability
|
||||
// surface required for "Add to Home Screen" in some browsers; offline
|
||||
// caching is intentionally not implemented yet.
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||
// Suppress notification if the user already has the relevant page focused
|
||||
for (const client of clientList) {
|
||||
if (client.visibilityState === 'visible' && client.url.includes(notifUrl)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return self.registration.showNotification(data.title || 'Fabled Assistant', {
|
||||
body: data.body || '',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
data: { url: notifUrl },
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||
const url = event.notification.data.url;
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(url) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// PWA: serve cached assets for offline support (minimal)
|
||||
self.addEventListener('fetch', event => {
|
||||
self.addEventListener('fetch', () => {
|
||||
// Pass-through — no offline caching in v1
|
||||
});
|
||||
|
||||
+7
-50
@@ -5,43 +5,26 @@ import AppHeader from "@/components/AppHeader.vue";
|
||||
import ToastNotification from "@/components/ToastNotification.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useOnnxPreloader } from "@/composables/useOnnxPreloader";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiGet, apiPut } from "@/api/client";
|
||||
|
||||
useTheme();
|
||||
|
||||
const { schedulePreload: scheduleVadPreload } = useOnnxPreloader();
|
||||
scheduleVadPreload();
|
||||
|
||||
const router = useRouter();
|
||||
const appVersion = ref("dev");
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||
|
||||
function startAppServices() {
|
||||
chatStore.startStatusPolling();
|
||||
settingsStore.fetchSettings();
|
||||
settingsStore.checkVoiceStatus();
|
||||
// Sync browser timezone to the server on every login/page load.
|
||||
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
|
||||
// Re-check voice status when the tab becomes visible again (model may
|
||||
// have finished loading while the user was away).
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === "visible" && authStore.isAuthenticated) {
|
||||
settingsStore.checkVoiceStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function stopAppServices() {
|
||||
chatStore.stopStatusPolling();
|
||||
// no-op for now; kept as a hook for future per-session teardown
|
||||
}
|
||||
|
||||
function isInputActive(): boolean {
|
||||
@@ -96,9 +79,10 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
case "n": router.push("/notes"); break;
|
||||
case "t": router.push("/"); break;
|
||||
case "p": router.push("/projects"); break;
|
||||
case "c": router.push("/chat"); break;
|
||||
case "r": router.push("/rules"); break;
|
||||
case "g": router.push("/graph"); break;
|
||||
case "l": router.push("/calendar"); break;
|
||||
case "x": router.push("/trash"); break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -126,13 +110,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
e.preventDefault();
|
||||
document.dispatchEvent(new CustomEvent("shortcut:focus-search"));
|
||||
break;
|
||||
case "c":
|
||||
if (router.currentRoute.value.name === "home") {
|
||||
document.dispatchEvent(new CustomEvent("shortcut:focus-chat"));
|
||||
} else {
|
||||
router.push("/chat");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +140,6 @@ watch(
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", onGlobalKeydown);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
stopAppServices();
|
||||
});
|
||||
</script>
|
||||
@@ -217,14 +193,14 @@ onUnmounted(() => {
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">c</kbd>
|
||||
<span class="shortcut-desc">Chat</span>
|
||||
<kbd class="shortcut-key">l</kbd>
|
||||
<span class="shortcut-desc">Calendar</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">l</kbd>
|
||||
<span class="shortcut-desc">Calendar</span>
|
||||
<kbd class="shortcut-key">x</kbd>
|
||||
<span class="shortcut-desc">Trash</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">Esc</kbd>
|
||||
@@ -267,23 +243,6 @@ onUnmounted(() => {
|
||||
<span class="shortcut-desc">Edit current item (viewer)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shortcuts-section">
|
||||
<div class="shortcuts-section-title">Chat</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">c</kbd>
|
||||
<span class="shortcut-desc">Focus chat (home) / go to chat</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">Enter</kbd>
|
||||
<span class="shortcut-desc">Send message</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">Shift</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">Enter</kbd>
|
||||
<span class="shortcut-desc">New line</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,8 +287,6 @@ onUnmounted(() => {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.app-content:has(.workspace-root),
|
||||
.app-content:has(.chat-page),
|
||||
.app-content:has(.knowledge-root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
+19
-240
@@ -299,152 +299,6 @@ export function apiSSEStream(
|
||||
return { close: () => controller.abort(), done };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Briefing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BriefingLocation {
|
||||
label: string;
|
||||
address: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface BriefingSlots {
|
||||
compilation: boolean;
|
||||
morning: boolean;
|
||||
midday: boolean;
|
||||
afternoon: boolean;
|
||||
}
|
||||
|
||||
export interface BriefingConfig {
|
||||
enabled: boolean;
|
||||
locations: {
|
||||
home?: BriefingLocation;
|
||||
work?: BriefingLocation;
|
||||
};
|
||||
use_caldav_event_locations: boolean;
|
||||
work_days: number[];
|
||||
slots: BriefingSlots;
|
||||
notifications: boolean;
|
||||
temp_unit: 'C' | 'F';
|
||||
}
|
||||
|
||||
export interface BriefingFeed {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string | null;
|
||||
last_fetched_at: string | null;
|
||||
}
|
||||
|
||||
export interface BriefingConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
briefing_date: string | null;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BriefingMessage {
|
||||
id: number;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
created_at: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
||||
enabled: false,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
};
|
||||
|
||||
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
||||
try {
|
||||
const data = await apiGet<BriefingConfig>('/api/briefing/config');
|
||||
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
|
||||
} catch {
|
||||
return { ...DEFAULT_BRIEFING_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
|
||||
await apiPut('/api/briefing/config', config);
|
||||
}
|
||||
|
||||
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
|
||||
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
|
||||
const body: Record<string, string> = { url };
|
||||
if (category?.trim()) body.category = category.trim();
|
||||
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body);
|
||||
return { ...data, last_fetched_at: null };
|
||||
}
|
||||
|
||||
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
|
||||
return apiPost('/api/briefing/feeds/refresh', {});
|
||||
}
|
||||
|
||||
export async function deleteBriefingFeed(id: number): Promise<void> {
|
||||
await apiDelete(`/api/briefing/feeds/${id}`);
|
||||
}
|
||||
|
||||
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
|
||||
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
|
||||
return data.conversations;
|
||||
}
|
||||
|
||||
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
|
||||
return apiGet('/api/briefing/conversations/today');
|
||||
}
|
||||
|
||||
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
|
||||
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
|
||||
return data.messages;
|
||||
}
|
||||
|
||||
export async function triggerBriefingSlot(slot: string): Promise<void> {
|
||||
await apiPost('/api/briefing/trigger', { slot });
|
||||
}
|
||||
|
||||
export async function postRssReaction(
|
||||
rssItemId: number,
|
||||
reaction: 'up' | 'down'
|
||||
): Promise<{ ok: boolean; action: string }> {
|
||||
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
|
||||
}
|
||||
|
||||
export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
||||
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
||||
}
|
||||
|
||||
export async function openArticleInChat(
|
||||
itemId: number,
|
||||
): Promise<{ conversation_id: number; assistant_message_id?: number; status?: string }> {
|
||||
return apiPost(`/api/chat/from-article/${itemId}`, {});
|
||||
}
|
||||
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||
try {
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
|
||||
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFableMcpInfo(): Promise<{ available: boolean; filename: string | null }> {
|
||||
return apiGet('/api/fable-mcp/info');
|
||||
}
|
||||
|
||||
export async function apiStreamPost(
|
||||
path: string,
|
||||
body: unknown,
|
||||
@@ -562,7 +416,7 @@ export interface EventUpdatePayload {
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string;
|
||||
recurrence?: string | null;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
@@ -604,93 +458,6 @@ export const createApiKey = (name: string, scope: 'read' | 'write') =>
|
||||
|
||||
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
|
||||
|
||||
// ─── News ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
export interface GetNewsItemsParams {
|
||||
days?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
feed_id?: number | null
|
||||
}
|
||||
|
||||
export function getNewsItems(params: GetNewsItemsParams = {}) {
|
||||
const p = new URLSearchParams()
|
||||
if (params.days != null) p.set('days', String(params.days))
|
||||
if (params.limit != null) p.set('limit', String(params.limit))
|
||||
if (params.offset != null) p.set('offset', String(params.offset))
|
||||
if (params.feed_id != null) p.set('feed_id', String(params.feed_id))
|
||||
return apiGet<{ items: NewsItem[]; offset: number; limit: number }>(
|
||||
`/api/briefing/news?${p}`
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Voice ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface VoiceStatusResult {
|
||||
enabled: boolean
|
||||
stt: boolean
|
||||
tts: boolean
|
||||
stt_model?: string
|
||||
tts_backend?: string
|
||||
}
|
||||
|
||||
export interface VoiceEntry {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface VoiceBlendEntry {
|
||||
voice: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export const getVoiceStatus = () => apiGet<VoiceStatusResult>('/api/voice/status')
|
||||
|
||||
export const getVoiceList = () =>
|
||||
apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices)
|
||||
|
||||
export async function transcribeAudio(blob: Blob, context?: string): Promise<{ transcript: string; duration_ms: number }> {
|
||||
const form = new FormData()
|
||||
form.append('audio', blob, 'audio.webm')
|
||||
if (context) form.append('context', context)
|
||||
const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||
throw new ApiError(res.status, err)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function synthesiseSpeech(
|
||||
text: string,
|
||||
voice?: string,
|
||||
speed?: number,
|
||||
voiceBlend?: VoiceBlendEntry[]
|
||||
): Promise<Blob> {
|
||||
// Only send voice/speed/blend when explicitly provided — omitting them lets
|
||||
// the server auto-load the user's saved voice settings (voice, speed, blend).
|
||||
const body: Record<string, unknown> = { text }
|
||||
if (voiceBlend && voiceBlend.length >= 2) {
|
||||
body.voice_blend = voiceBlend
|
||||
if (speed !== undefined) body.speed = speed
|
||||
} else if (voice !== undefined || speed !== undefined) {
|
||||
body.voice = voice ?? 'af_heart'
|
||||
body.speed = speed ?? 1.0
|
||||
}
|
||||
const res = await fetch('/api/voice/synthesise', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||
throw new ApiError(res.status, err)
|
||||
}
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
// ── User Profile ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserProfile {
|
||||
@@ -702,14 +469,26 @@ export interface UserProfile {
|
||||
tone: 'casual' | 'professional' | 'technical'
|
||||
interests: string[]
|
||||
work_schedule: { days?: string[]; start?: string; end?: string }
|
||||
learned_summary: string
|
||||
observations_count: number
|
||||
observations_updated_at: string | null
|
||||
}
|
||||
|
||||
export const getProfile = () => apiGet<UserProfile>('/api/profile')
|
||||
export const updateProfile = (data: Partial<UserProfile>) =>
|
||||
apiPut<UserProfile>('/api/profile', data)
|
||||
export const consolidateProfile = () =>
|
||||
apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {})
|
||||
export const clearProfileObservations = () => apiDelete('/api/profile/observations')
|
||||
|
||||
|
||||
// ── Note Versions (pinning) ──────────────────────────────────────────────────
|
||||
|
||||
import type { NoteVersion } from '../types/task'
|
||||
|
||||
/** Mark a note version as manually pinned, optionally with a commit-note
|
||||
* label. Re-calling with a different label updates the label. */
|
||||
export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) =>
|
||||
apiPost<NoteVersion>(
|
||||
`/api/notes/${noteId}/versions/${versionId}/pin`,
|
||||
{ label: label ?? null },
|
||||
)
|
||||
|
||||
/** Downgrade a manually-pinned version back to rolling. Does NOT delete
|
||||
* the row — older rows may be FIFO-pruned by the next autosave. */
|
||||
export const unpinNoteVersion = (noteId: number, versionId: number) =>
|
||||
apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
export interface Rulebook {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface RulebookTopic {
|
||||
id: number;
|
||||
rulebook_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
id: number;
|
||||
topic_id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface RuleHeader {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number;
|
||||
}
|
||||
|
||||
export interface ApplicableRules {
|
||||
rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_title: string;
|
||||
rulebook_title: string;
|
||||
}[];
|
||||
truncated: boolean;
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
}
|
||||
|
||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||
|
||||
export async function listRulebooks(): Promise<Rulebook[]> {
|
||||
const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks");
|
||||
return data.rulebooks;
|
||||
}
|
||||
|
||||
export async function getRulebook(id: number): Promise<Rulebook & { topics: RulebookTopic[] }> {
|
||||
return apiGet(`/api/rulebooks/${id}`);
|
||||
}
|
||||
|
||||
export async function createRulebook(data: { title: string; description?: string }): Promise<Rulebook> {
|
||||
return apiPost("/api/rulebooks", data);
|
||||
}
|
||||
|
||||
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
|
||||
return apiPatch(`/api/rulebooks/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteRulebook(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rulebooks/${id}`);
|
||||
}
|
||||
|
||||
// ── Topics ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function listTopics(rulebookId: number): Promise<RulebookTopic[]> {
|
||||
const data = await apiGet<{ topics: RulebookTopic[] }>(`/api/rulebooks/${rulebookId}/topics`);
|
||||
return data.topics;
|
||||
}
|
||||
|
||||
export async function createTopic(rulebookId: number, data: { title: string; description?: string; order_index?: number }): Promise<RulebookTopic> {
|
||||
return apiPost(`/api/rulebooks/${rulebookId}/topics`, data);
|
||||
}
|
||||
|
||||
export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise<RulebookTopic> {
|
||||
return apiPatch(`/api/rulebook-topics/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteTopic(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rulebook-topics/${id}`);
|
||||
}
|
||||
|
||||
// ── Rules ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise<Rule[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.rulebook_id) params.set("rulebook_id", String(filters.rulebook_id));
|
||||
if (filters.topic_id) params.set("topic_id", String(filters.topic_id));
|
||||
if (filters.project_id) params.set("project_id", String(filters.project_id));
|
||||
const qs = params.toString();
|
||||
const data = await apiGet<{ rules: Rule[] }>(`/api/rules${qs ? `?${qs}` : ""}`);
|
||||
return data.rules;
|
||||
}
|
||||
|
||||
export async function getRule(id: number): Promise<Rule> {
|
||||
return apiGet(`/api/rules/${id}`);
|
||||
}
|
||||
|
||||
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
|
||||
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
||||
}
|
||||
|
||||
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> {
|
||||
return apiPatch(`/api/rules/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteRule(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rules/${id}`);
|
||||
}
|
||||
|
||||
// ── Subscriptions ──────────────────────────────────────────────────
|
||||
|
||||
export async function subscribeProject(projectId: number, rulebookId: number): Promise<void> {
|
||||
await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId });
|
||||
}
|
||||
|
||||
export async function unsubscribeProject(projectId: number, rulebookId: number): Promise<void> {
|
||||
return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`);
|
||||
}
|
||||
|
||||
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
||||
return apiGet(`/api/projects/${projectId}/rules`);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { apiGet, apiPost, apiDelete } from "@/api/client";
|
||||
|
||||
export interface TrashItem {
|
||||
type: string;
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TrashBatch {
|
||||
batch_id: string;
|
||||
deleted_at: string | null;
|
||||
summary: string;
|
||||
count: number;
|
||||
items: TrashItem[];
|
||||
}
|
||||
|
||||
export async function listTrash(): Promise<TrashBatch[]> {
|
||||
const data = await apiGet<{ batches: TrashBatch[] }>("/api/trash");
|
||||
return data.batches;
|
||||
}
|
||||
|
||||
export async function restoreBatch(batchId: string): Promise<void> {
|
||||
await apiPost(`/api/trash/${batchId}/restore`, {});
|
||||
}
|
||||
|
||||
export async function purgeBatch(batchId: string): Promise<void> {
|
||||
return apiDelete(`/api/trash/${batchId}`);
|
||||
}
|
||||
@@ -50,34 +50,42 @@
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
|
||||
the software" — not a brand moment. Accent gradient is reserved for
|
||||
Send / empty-state CTAs. */
|
||||
.btn-save {
|
||||
padding: 0.45rem 1.1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
opacity: 0.95;
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-save:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
|
||||
with a Trash icon at the call site to reinforce intent. */
|
||||
.btn-delete {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-danger);
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-delete:hover { background: var(--color-action-destructive-hover); }
|
||||
.btn-assist-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.4rem 0.9rem;
|
||||
@@ -99,7 +107,7 @@
|
||||
border-bottom: 1.5px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
@@ -112,7 +120,6 @@
|
||||
}
|
||||
.title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
.editor-tabs {
|
||||
@@ -225,7 +232,7 @@
|
||||
.assist-panel-title {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -269,7 +276,7 @@
|
||||
/* Section list */
|
||||
.assist-sections-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -362,7 +369,6 @@
|
||||
.assist-streaming-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -391,7 +397,6 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -411,7 +416,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-toggle-view {
|
||||
@@ -454,7 +459,7 @@
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
}
|
||||
.diff-text {
|
||||
flex: 1;
|
||||
@@ -464,7 +469,6 @@
|
||||
.diff-empty {
|
||||
padding: 0.5rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.assist-actions {
|
||||
@@ -562,7 +566,7 @@
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
@@ -581,7 +585,7 @@
|
||||
}
|
||||
.sb-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
/* Long-form reading line-height per the design system —
|
||||
reading surfaces (notes, tasks, journal, assistant bubbles) want 1.7
|
||||
so prose breathes like a book, not a UI snippet. */
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
|
||||
+149
-114
@@ -1,131 +1,147 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
|
||||
|
||||
: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;
|
||||
/* Light mode — warm parchment palette */
|
||||
--color-bg: #F5F1E8;
|
||||
--color-bg-secondary: #FBF8F0;
|
||||
--color-bg-card: #FBF8F0;
|
||||
--color-surface: #EFEAE0;
|
||||
--color-text: #14171A;
|
||||
--color-text-secondary: #5A5852;
|
||||
--color-text-muted: #9A9890;
|
||||
--color-border: #D9D6CE;
|
||||
--color-input-border: #D9D6CE;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--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-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.12);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #EBEDF0;
|
||||
--color-code-inline-bg: #EBEDF0;
|
||||
--color-table-stripe: rgba(20, 23, 26, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #EFEAE0;
|
||||
--color-input-bar-text: #14171A;
|
||||
--color-input-bar-placeholder: rgba(20, 23, 26, 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);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #D9D6CE;
|
||||
--color-bubble-user-text: #5A5852;
|
||||
--color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.08);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.12);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.20);
|
||||
|
||||
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
|
||||
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
--chat-reading-width: min(1200px, 100%);
|
||||
--chat-context-sidebar-width: 220px;
|
||||
--color-accent-warm: #b8860b;
|
||||
--color-accent-warm-light: #d4a017;
|
||||
--color-primary-solid: #7c3aed;
|
||||
--color-primary-deep: #5b21b6;
|
||||
/* Reusable brand patterns */
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(124, 58, 237, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(124, 58, 237, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(124, 58, 237, 0.35);
|
||||
--color-primary-faint: rgba(124, 58, 237, 0.08);
|
||||
--color-primary-tint: rgba(124, 58, 237, 0.12);
|
||||
--color-primary-wash: rgba(124, 58, 237, 0.20);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #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.22);
|
||||
--color-input-border: rgba(124, 58, 237, 0.35);
|
||||
--color-primary: #a78bfa;
|
||||
--color-danger: #f44336;
|
||||
--color-tag-bg: #2a2a45;
|
||||
--color-tag-text: #c4b5fd;
|
||||
/* Dark mode — Obsidian / Iron / Pewter */
|
||||
--color-bg: #14171A;
|
||||
--color-bg-secondary: #1E2228;
|
||||
--color-bg-card: #1E2228;
|
||||
--color-surface: #2C313A;
|
||||
--color-text: #E8E4D8;
|
||||
--color-text-secondary: #C2BFB4;
|
||||
--color-text-muted: #9C9A92;
|
||||
--color-border: #3F4651;
|
||||
--color-input-border: #3F4651;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.15);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--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-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.18);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #14171A;
|
||||
--color-code-inline-bg: #1E2228;
|
||||
--color-table-stripe: rgba(255, 255, 255, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #1E2228;
|
||||
--color-input-bar-text: #E8E4D8;
|
||||
--color-input-bar-placeholder: rgba(232, 228, 216, 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;
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #3F4651;
|
||||
--color-bubble-user-text: #C2BFB4;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 12px rgba(124, 58, 237, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(124, 58, 237, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(124, 58, 237, 0.4);
|
||||
--color-primary-faint: rgba(124, 58, 237, 0.10);
|
||||
--color-primary-tint: rgba(124, 58, 237, 0.14);
|
||||
--color-primary-wash: rgba(124, 58, 237, 0.22);
|
||||
--glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.10);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.14);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.22);
|
||||
|
||||
/* Action color set — identical across themes */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -138,15 +154,34 @@ body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
font-feature-settings: "cv11";
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
h1, h2 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
code, pre, kbd, samp {
|
||||
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-feature-settings: "liga", "calt";
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(91, 74, 138, 0.3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
@@ -184,7 +219,7 @@ button:not(:disabled):active,
|
||||
}
|
||||
}
|
||||
|
||||
/* Thin indigo-tinted scrollbars */
|
||||
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
@@ -193,11 +228,11 @@ button:not(:disabled):active,
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(124, 58, 237, 0.45);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||
|
||||
@@ -4,49 +4,20 @@ import { useRouter, useRoute } from "vue-router";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const isChatActive = computed(() => route.path.startsWith("/chat"))
|
||||
const isKnowledgeActive = computed(() => route.path === "/" || route.path === "/knowledge");
|
||||
const isKnowledgeActive = computed(() => route.path === "/knowledge");
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
|
||||
const statusShortLabel = computed(() => {
|
||||
if (chatStore.ollamaStatus === "unavailable") return "Offline";
|
||||
if (chatStore.ollamaStatus === "checking") return "Checking";
|
||||
if (chatStore.modelStatus === "not_found") return "No model";
|
||||
if (chatStore.modelStatus === "cold") return "Cold";
|
||||
if (chatStore.modelStatus === "loaded") return "Ready";
|
||||
return "Checking";
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (chatStore.ollamaStatus === "unavailable") return "Ollama unavailable";
|
||||
if (chatStore.ollamaStatus === "checking") return "Checking...";
|
||||
if (chatStore.modelStatus === "not_found") return "Model not installed";
|
||||
if (chatStore.modelStatus === "cold") return "Model cold — first response may be slow";
|
||||
if (chatStore.modelStatus === "loaded") return "Model loaded · ready";
|
||||
return "Checking...";
|
||||
});
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (chatStore.ollamaStatus === "unavailable") return "status-red";
|
||||
if (chatStore.ollamaStatus === "checking") return "status-gray";
|
||||
if (chatStore.modelStatus === "not_found") return "status-orange";
|
||||
if (chatStore.modelStatus === "cold") return "status-yellow";
|
||||
if (chatStore.modelStatus === "loaded") return "status-green";
|
||||
return "status-gray";
|
||||
});
|
||||
|
||||
function toggleMobileMenu() {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||
}
|
||||
@@ -74,36 +45,32 @@ router.afterEach(() => {
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<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="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</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>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: status + utilities + gear + user -->
|
||||
<!-- Right: utilities + gear + user -->
|
||||
<div class="nav-right">
|
||||
<span class="status-indicator" :class="statusClass" :title="statusLabel">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ statusShortLabel }}</span>
|
||||
</span>
|
||||
|
||||
<NotificationBell />
|
||||
|
||||
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
|
||||
|
||||
<button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
||||
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
||||
<Sun v-if="theme === 'dark'" :size="16" />
|
||||
<Moon v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Trash link -->
|
||||
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
||||
<Trash2 :size="16" />
|
||||
</router-link>
|
||||
|
||||
<!-- Settings link -->
|
||||
<router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<Settings :size="16" />
|
||||
</router-link>
|
||||
|
||||
<div class="user-info">
|
||||
@@ -123,23 +90,19 @@ router.afterEach(() => {
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<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="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/trash" class="nav-link">Trash</router-link>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<div class="mobile-actions">
|
||||
<span class="status-indicator" :class="statusClass" :title="statusLabel">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ statusShortLabel }}</span>
|
||||
</span>
|
||||
<button class="btn-icon" @click="toggleShortcuts">?</button>
|
||||
<button class="btn-icon" @click="toggleTheme">{{ theme === "dark" ? "\u2600" : "\u263E" }}</button>
|
||||
<button class="btn-icon" @click="toggleTheme"><Sun v-if="theme === 'dark'" :size="16" />
|
||||
<Moon v-else :size="16" /></button>
|
||||
</div>
|
||||
<div class="mobile-user">
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
@@ -153,7 +116,7 @@ router.afterEach(() => {
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.18);
|
||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
@@ -174,9 +137,8 @@ router.afterEach(() => {
|
||||
}
|
||||
.brand-text {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: #c4b0f0;
|
||||
@@ -221,9 +183,9 @@ router.afterEach(() => {
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary-solid);
|
||||
font-weight: 600;
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
|
||||
font-weight: 500;
|
||||
background: rgba(91, 74, 138, 0.25);
|
||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -245,10 +207,14 @@ router.afterEach(() => {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; }
|
||||
/* Status dots are indicator lights, not semantic-palette buttons —
|
||||
they want to read as vital (Moss/Warning/Error are too muted for
|
||||
a "ready" indicator). Hardcoded bright values; the rest of the
|
||||
system still uses the semantic tokens. */
|
||||
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
|
||||
.status-orange .status-dot { background: #f97316; }
|
||||
.status-red .status-dot { background: var(--color-danger, #e74c3c); }
|
||||
.status-red .status-dot { background: #ef4444; }
|
||||
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
@@ -296,7 +262,7 @@ router.afterEach(() => {
|
||||
}
|
||||
.admin-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-primary);
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import {
|
||||
saveBriefingConfig,
|
||||
geocodeAddress,
|
||||
createBriefingFeed,
|
||||
type BriefingConfig,
|
||||
} from '@/api/client'
|
||||
|
||||
const emit = defineEmits<{ done: [] }>()
|
||||
|
||||
const step = ref(1)
|
||||
const TOTAL_STEPS = 4
|
||||
|
||||
const config = reactive<BriefingConfig>({
|
||||
enabled: true,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
})
|
||||
|
||||
// Step 2 — locations
|
||||
const homeAddress = ref('')
|
||||
const workAddress = ref('')
|
||||
const geocodingHome = ref(false)
|
||||
const geocodingWork = ref(false)
|
||||
const homeConfirmed = ref<string | null>(null)
|
||||
const workConfirmed = ref<string | null>(null)
|
||||
const homeError = ref('')
|
||||
const workError = ref('')
|
||||
|
||||
async function lookupHome() {
|
||||
if (!homeAddress.value.trim()) return
|
||||
geocodingHome.value = true
|
||||
homeError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(homeAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.home = { label: 'Home', address: homeAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
homeConfirmed.value = r.display_name
|
||||
} else {
|
||||
homeError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingHome.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupWork() {
|
||||
if (!workAddress.value.trim()) return
|
||||
geocodingWork.value = true
|
||||
workError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(workAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.work = { label: 'Work', address: workAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
workConfirmed.value = r.display_name
|
||||
} else {
|
||||
workError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingWork.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — work days
|
||||
function toggleDay(d: number) {
|
||||
const idx = config.work_days.indexOf(d)
|
||||
if (idx === -1) config.work_days.push(d)
|
||||
else config.work_days.splice(idx, 1)
|
||||
config.work_days.sort()
|
||||
}
|
||||
|
||||
// Step 4 — RSS feeds
|
||||
const newFeedUrl = ref('')
|
||||
const pendingFeeds = ref<Array<{ url: string }>>([])
|
||||
|
||||
function addPendingFeed() {
|
||||
if (!newFeedUrl.value.trim()) return
|
||||
pendingFeeds.value.push({ url: newFeedUrl.value.trim() })
|
||||
newFeedUrl.value = ''
|
||||
}
|
||||
function removePendingFeed(i: number) { pendingFeeds.value.splice(i, 1) }
|
||||
|
||||
// Finish
|
||||
const finishing = ref(false)
|
||||
async function finish() {
|
||||
finishing.value = true
|
||||
try {
|
||||
await saveBriefingConfig(config)
|
||||
for (const f of pendingFeeds.value) {
|
||||
try { await createBriefingFeed(f.url) } catch { /* best-effort */ }
|
||||
}
|
||||
emit('done')
|
||||
} finally {
|
||||
finishing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="wizard-overlay">
|
||||
<div class="wizard-card" role="dialog" aria-label="Briefing Setup">
|
||||
<!-- Progress bar -->
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-progress-fill" :style="{ width: `${(step / TOTAL_STEPS) * 100}%` }"></div>
|
||||
</div>
|
||||
<div class="wizard-step-label">Step {{ step }} of {{ TOTAL_STEPS }}</div>
|
||||
|
||||
<!-- Step 1: Welcome -->
|
||||
<div v-if="step === 1" class="wizard-body">
|
||||
<h2 class="wizard-title">Good morning.</h2>
|
||||
<p class="wizard-text">
|
||||
The Briefing is a daily conversation that summarises your day — tasks, calendar,
|
||||
weather, and news — and checks in a few times throughout the day.
|
||||
</p>
|
||||
<p class="wizard-text">
|
||||
It learns from your responses over time and adapts to your schedule.
|
||||
Let's take a minute to set it up.
|
||||
</p>
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-primary" @click="step = 2">Get started</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Locations -->
|
||||
<div v-else-if="step === 2" class="wizard-body">
|
||||
<h2 class="wizard-title">Where are you?</h2>
|
||||
<p class="wizard-text">
|
||||
Enter your home and work addresses. The briefing uses these to fetch weather
|
||||
for the right places. You can skip either one.
|
||||
</p>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Home address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="homeAddress" class="wizard-input" placeholder="e.g. 123 Main St, Springfield" @keydown.enter="lookupHome" />
|
||||
<button class="btn-wizard-secondary" @click="lookupHome" :disabled="geocodingHome">
|
||||
{{ geocodingHome ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="homeConfirmed" class="wizard-confirmed">✓ {{ homeConfirmed }}</div>
|
||||
<div v-if="homeError" class="wizard-error">{{ homeError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Work address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="workAddress" class="wizard-input" placeholder="e.g. 456 Office Ave, Portland" @keydown.enter="lookupWork" />
|
||||
<button class="btn-wizard-secondary" @click="lookupWork" :disabled="geocodingWork">
|
||||
{{ geocodingWork ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="workConfirmed" class="wizard-confirmed">✓ {{ workConfirmed }}</div>
|
||||
<div v-if="workError" class="wizard-error">{{ workError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 1">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 3">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Work schedule -->
|
||||
<div v-else-if="step === 3" class="wizard-body">
|
||||
<h2 class="wizard-title">When do you go to the office?</h2>
|
||||
<p class="wizard-text">
|
||||
The 8am slot is labelled "you're at the office" on days you have marked as office days.
|
||||
Toggle any days you typically commute.
|
||||
</p>
|
||||
|
||||
<div class="wizard-days">
|
||||
<button
|
||||
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="idx"
|
||||
:class="['wizard-day-btn', { active: config.work_days.includes(idx) }]"
|
||||
@click="toggleDay(idx)"
|
||||
type="button"
|
||||
>{{ day }}</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 2">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 4">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: RSS feeds -->
|
||||
<div v-else-if="step === 4" class="wizard-body">
|
||||
<h2 class="wizard-title">What do you follow?</h2>
|
||||
<p class="wizard-text">
|
||||
Add RSS or Atom feeds and the briefing will summarise recent items each morning.
|
||||
You can add or remove feeds any time in Settings.
|
||||
</p>
|
||||
|
||||
<div v-if="pendingFeeds.length" class="wizard-feeds-list">
|
||||
<div v-for="(f, i) in pendingFeeds" :key="i" class="wizard-feed-row">
|
||||
<span class="wizard-feed-url">{{ f.url }}</span>
|
||||
<button class="btn-wizard-remove" @click="removePendingFeed(i)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-add-feed">
|
||||
<input v-model="newFeedUrl" class="wizard-input" placeholder="https://…/feed.xml" style="flex: 1" />
|
||||
<button class="btn-wizard-secondary" @click="addPendingFeed" :disabled="!newFeedUrl.trim()">Add</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions" style="margin-top: 1.5rem">
|
||||
<button class="btn-wizard-ghost" @click="step = 3">Back</button>
|
||||
<button class="btn-wizard-ghost" @click="finish" :disabled="finishing">Skip</button>
|
||||
<button class="btn-wizard-primary" @click="finish" :disabled="finishing">
|
||||
{{ finishing ? 'Setting up…' : 'Enable Briefing' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wizard-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.wizard-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
width: 520px;
|
||||
max-width: 94vw;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.wizard-progress {
|
||||
height: 3px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.wizard-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--gradient-cta);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.wizard-step-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
padding: 0.5rem 1.5rem 0;
|
||||
}
|
||||
.wizard-body {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
}
|
||||
.wizard-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.wizard-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.wizard-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.wizard-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.wizard-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wizard-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-input:focus { border-color: var(--color-primary); }
|
||||
.wizard-confirmed {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-success, #22c55e);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-error {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-danger, #ef4444);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-days {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.wizard-day-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-day-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.wizard-feeds-list {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.wizard-feed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wizard-feed-url {
|
||||
flex: 1;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-wizard-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-wizard-remove:hover { color: var(--color-danger, #ef4444); }
|
||||
.wizard-add-feed {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-wizard-primary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-secondary {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-ghost {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-ghost:hover { background: var(--color-bg-secondary); }
|
||||
.btn-wizard-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import type { ToolCallRecord } from "@/types/chat";
|
||||
|
||||
const props = defineProps<{
|
||||
toolCalls: ToolCallRecord[];
|
||||
}>();
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
function shortName(fn: string): string {
|
||||
return fn
|
||||
.replace(/^(list|get|search|fetch)_/, "")
|
||||
.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
interface Pill {
|
||||
key: string;
|
||||
name: string;
|
||||
count: string | null;
|
||||
state: "ok" | "empty" | "error";
|
||||
}
|
||||
|
||||
const pills = computed<Pill[]>(() =>
|
||||
props.toolCalls.map((tc, i) => {
|
||||
const ok = tc.result?.success !== false && tc.status !== "error";
|
||||
const data = tc.result?.data as Record<string, unknown> | undefined;
|
||||
let count: string | null = null;
|
||||
let state: Pill["state"] = ok ? "ok" : "error";
|
||||
if (ok && data) {
|
||||
const raw =
|
||||
(typeof data.count === "number" && data.count) ||
|
||||
(typeof data.total === "number" && data.total);
|
||||
if (typeof raw === "number") {
|
||||
count = String(raw);
|
||||
if (raw === 0) state = "empty";
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: `${tc.function}-${i}`,
|
||||
name: shortName(tc.function),
|
||||
count,
|
||||
state,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const errorCount = computed(() => pills.value.filter((p) => p.state === "error").length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="briefing-status" :class="{ expanded }">
|
||||
<button class="briefing-status-toggle" @click="expanded = !expanded">
|
||||
<span class="briefing-status-label">Gathered</span>
|
||||
<span
|
||||
v-for="p in pills"
|
||||
:key="p.key"
|
||||
class="briefing-status-pill"
|
||||
:class="`state-${p.state}`"
|
||||
>
|
||||
{{ p.name }}<span v-if="p.count != null" class="pill-count">{{ p.count }}</span>
|
||||
</span>
|
||||
<span v-if="errorCount" class="briefing-status-errcount">{{ errorCount }} failed</span>
|
||||
<span class="briefing-status-chevron" :class="{ open: expanded }">▶</span>
|
||||
</button>
|
||||
<div v-if="expanded" class="briefing-status-detail">
|
||||
<ToolCallCard
|
||||
v-for="(tc, i) in toolCalls"
|
||||
:key="i"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.briefing-status {
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.briefing-status-toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--color-text-muted);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.briefing-status-toggle:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
|
||||
}
|
||||
|
||||
.briefing-status-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.85;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.briefing-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.08rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-card);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.briefing-status-pill.state-empty {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.briefing-status-pill.state-error {
|
||||
border-color: color-mix(in srgb, var(--color-danger, #ef4444) 60%, var(--color-border));
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.pill-count {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.state-error .pill-count {
|
||||
background: color-mix(in srgb, var(--color-danger, #ef4444) 18%, transparent);
|
||||
}
|
||||
|
||||
.briefing-status-errcount {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-danger, #ef4444);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.briefing-status-chevron {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.5;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.briefing-status-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.briefing-status-detail {
|
||||
margin-top: 0.4rem;
|
||||
padding-left: 0.55rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,666 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { apiGet, transcribeAudio } from '@/api/client'
|
||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
||||
import { useVad } from '@/composables/useVad'
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
||||
import { useListenMode } from '@/composables/useListenMode'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { Note } from '@/types/note'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Textarea placeholder */
|
||||
placeholder?: string
|
||||
/** When true, hides the note picker (briefing mode) */
|
||||
briefingMode?: boolean
|
||||
/** Pill shape — compact rounded style for widget */
|
||||
pill?: boolean
|
||||
}>(), {
|
||||
placeholder: 'Type a message… (Enter to send, Shift+Enter for new line)',
|
||||
briefingMode: false,
|
||||
pill: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { content: string; contextNoteId?: number }]
|
||||
abort: []
|
||||
}>()
|
||||
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
|
||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||||
|
||||
// ── Streaming TTS (listen mode) ───────────────────────────────────────────────
|
||||
const listenMode = useListenMode()
|
||||
const audio = useVoiceAudio()
|
||||
const speakerPopoverOpen = ref(false)
|
||||
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => store.streamingContent),
|
||||
streaming: computed(() => store.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
|
||||
function lastAssistantContent(): string {
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
|
||||
}
|
||||
|
||||
function toggleListen() {
|
||||
listenMode.value = !listenMode.value
|
||||
if (listenMode.value) {
|
||||
tts.speak(lastAssistantContent())
|
||||
} else {
|
||||
tts.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core input ────────────────────────────────────────────────────────────────
|
||||
const messageInput = ref('')
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||||
const wrapperEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function autoResize() {
|
||||
const el = inputEl.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
|
||||
}
|
||||
|
||||
function resetTextareaHeight() {
|
||||
const el = inputEl.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
const content = messageInput.value.trim()
|
||||
if (!content) return
|
||||
emit('submit', { content, contextNoteId: attachedNote.value?.id })
|
||||
messageInput.value = ''
|
||||
attachedNote.value = null
|
||||
resetTextareaHeight()
|
||||
}
|
||||
|
||||
// ── Note picker ───────────────────────────────────────────────────────────────
|
||||
const attachedNote = ref<{ id: number; title: string } | null>(null)
|
||||
const showNotePicker = ref(false)
|
||||
const noteSearchQuery = ref('')
|
||||
const noteSearchResults = ref<{ id: number; title: string }[]>([])
|
||||
const noteSearchLoading = ref(false)
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function toggleNotePicker() {
|
||||
showNotePicker.value = !showNotePicker.value
|
||||
if (showNotePicker.value) {
|
||||
noteSearchQuery.value = ''
|
||||
noteSearchResults.value = []
|
||||
nextTick(() => {
|
||||
(wrapperEl.value?.querySelector('.note-picker-search') as HTMLInputElement)?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onNoteSearchInput() {
|
||||
if (noteSearchTimer) clearTimeout(noteSearchTimer)
|
||||
noteSearchTimer = setTimeout(async () => {
|
||||
const q = noteSearchQuery.value.trim()
|
||||
if (!q) { noteSearchResults.value = []; return }
|
||||
noteSearchLoading.value = true
|
||||
try {
|
||||
const data = await apiGet<{ notes: Note[] }>(`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`)
|
||||
noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title }))
|
||||
} catch {
|
||||
noteSearchResults.value = []
|
||||
} finally {
|
||||
noteSearchLoading.value = false
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function selectNote(note: { id: number; title: string }) {
|
||||
attachedNote.value = note
|
||||
showNotePicker.value = false
|
||||
}
|
||||
|
||||
function removeAttachedNote() {
|
||||
attachedNote.value = null
|
||||
}
|
||||
|
||||
// ── Voice (click-to-toggle + VAD speech detection) ─────────────────────────
|
||||
const transcribingVoice = ref(false)
|
||||
const recorder = useVoiceRecorder()
|
||||
// Tracks whether VAD detected speech during the current recording session.
|
||||
// Used to skip the Whisper round-trip when the user manually stops without
|
||||
// ever speaking — the recording is ambient noise and will transcribe to "".
|
||||
const vadSawSpeech = ref(false)
|
||||
|
||||
const vad = useVad({
|
||||
onSpeechEnd: () => {
|
||||
// VAD auto-stop: speech was detected and the user has paused. Proceed
|
||||
// to transcribe the MediaRecorder output.
|
||||
void stopRecording(false)
|
||||
},
|
||||
onNoSpeech: () => {
|
||||
// User manually stopped without VAD detecting speech. Toast it; the
|
||||
// stopRecording path below will skip Whisper based on vadSawSpeech.
|
||||
useToastStore().show('No speech detected', 'warning')
|
||||
},
|
||||
})
|
||||
|
||||
// Live mic halo. A solid red disc sits behind the mic button and scales
|
||||
// with `vad.amplitude` (0..1 RMS, already amplified for visibility).
|
||||
const micGlowStyle = computed(() => {
|
||||
if (!recorder.recording.value) return { display: 'none' }
|
||||
const pulse = 0.2 + Math.min(vad.amplitude.value, 1) * 0.8
|
||||
return {
|
||||
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
|
||||
opacity: String(0.45 + pulse * 0.4),
|
||||
}
|
||||
})
|
||||
|
||||
// vad.speaking flips true on speech-start. Watch it once per session to
|
||||
// capture that speech was detected at some point, without clearing when
|
||||
// speech ends. This drives the no-speech guard in stopRecording.
|
||||
watch(() => vad.speaking.value, (on) => {
|
||||
if (on) vadSawSpeech.value = true
|
||||
})
|
||||
|
||||
async function toggleVoice() {
|
||||
if (transcribingVoice.value) return
|
||||
if (recorder.recording.value) {
|
||||
await stopRecording(true)
|
||||
} else {
|
||||
await startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
if (!voiceEnabled.value || recorder.recording.value) return
|
||||
if (!recorder.isSupported) {
|
||||
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
|
||||
return
|
||||
}
|
||||
vadSawSpeech.value = false
|
||||
await recorder.startRecording()
|
||||
if (recorder.error.value) {
|
||||
useToastStore().show(recorder.error.value, 'error')
|
||||
return
|
||||
}
|
||||
if (recorder.stream.value) {
|
||||
await vad.start(recorder.stream.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording(manual: boolean) {
|
||||
if (manual) {
|
||||
await vad.stopAndCheck()
|
||||
} else {
|
||||
await vad.stop()
|
||||
}
|
||||
if (!recorder.recording.value) return
|
||||
transcribingVoice.value = true
|
||||
try {
|
||||
const blob = await recorder.stopRecording()
|
||||
// No-speech guard: user manually stopped without VAD seeing speech.
|
||||
// Skip Whisper — the toast was already shown by onNoSpeech.
|
||||
if (manual && !vadSawSpeech.value) {
|
||||
return
|
||||
}
|
||||
// Pass last assistant message as context to reduce STT mishearings
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
|
||||
const { transcript } = await transcribeAudio(blob, lastAssistant)
|
||||
if (transcript.trim()) {
|
||||
messageInput.value = transcript.trim()
|
||||
await nextTick()
|
||||
autoResize()
|
||||
onSubmit()
|
||||
}
|
||||
} catch { /* transcription failed silently */ }
|
||||
finally { transcribingVoice.value = false }
|
||||
}
|
||||
|
||||
// ── Click-outside to dismiss speaker popover ──────────────────────────────────
|
||||
function onDocumentMousedown(e: MouseEvent) {
|
||||
if (!speakerPopoverOpen.value) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target?.closest('.speaker-wrapper')) speakerPopoverOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('mousedown', onDocumentMousedown))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', onDocumentMousedown))
|
||||
|
||||
// ── Exposed interface ─────────────────────────────────────────────────────────
|
||||
function focus() {
|
||||
inputEl.value?.focus()
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
messageInput.value = text
|
||||
nextTick(() => {
|
||||
autoResize()
|
||||
inputEl.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ focus, prefill })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapperEl" class="chat-input-bar" :class="{ 'chat-input-bar--pill': pill }">
|
||||
<!-- Attached note pill -->
|
||||
<div v-if="attachedNote" class="attached-note">
|
||||
<span class="attached-note-pill">
|
||||
{{ attachedNote.title }}
|
||||
<button class="attached-note-remove" aria-label="Remove" @click="removeAttachedNote">×</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<!-- Note picker -->
|
||||
<div class="note-picker-wrapper">
|
||||
<button
|
||||
class="btn-icon"
|
||||
@click="toggleNotePicker"
|
||||
:disabled="!store.chatReady"
|
||||
title="Attach a note"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showNotePicker" class="note-picker-dropdown">
|
||||
<input
|
||||
class="note-picker-search"
|
||||
v-model="noteSearchQuery"
|
||||
@input="onNoteSearchInput"
|
||||
placeholder="Search notes..."
|
||||
/>
|
||||
<div class="note-picker-results">
|
||||
<div
|
||||
v-for="note in noteSearchResults"
|
||||
:key="note.id"
|
||||
class="note-picker-item"
|
||||
@click="selectNote(note)"
|
||||
>{{ note.title || 'Untitled' }}</div>
|
||||
<div v-if="noteSearchLoading" class="note-picker-empty">Searching...</div>
|
||||
<div v-else-if="noteSearchQuery && !noteSearchResults.length" class="note-picker-empty">No notes found</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea -->
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
:placeholder="!store.chatReady ? 'Chat unavailable' : store.streaming ? 'Type to queue… (Enter to queue)' : placeholder"
|
||||
:disabled="!store.chatReady"
|
||||
rows="1"
|
||||
class="input-textarea"
|
||||
></textarea>
|
||||
|
||||
<!-- Speaker / listen-mode popover -->
|
||||
<div v-if="voiceTtsEnabled" class="speaker-wrapper">
|
||||
<button
|
||||
class="btn-icon btn-speaker"
|
||||
:class="{ 'speaker-active': listenMode, 'speaker-busy': tts.speaking.value }"
|
||||
@click="speakerPopoverOpen = !speakerPopoverOpen"
|
||||
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
|
||||
aria-label="Listen and volume settings"
|
||||
>
|
||||
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="speakerPopoverOpen" class="speaker-popover">
|
||||
<button
|
||||
class="speaker-row speaker-row--toggle"
|
||||
:class="{ 'speaker-row--active': listenMode }"
|
||||
@click="toggleListen"
|
||||
>
|
||||
<span class="speaker-row-label">{{ listenMode ? 'Listening' : 'Read aloud' }}</span>
|
||||
<span class="speaker-switch" :class="{ on: listenMode }"></span>
|
||||
</button>
|
||||
<div class="speaker-row">
|
||||
<span class="speaker-row-label">Volume</span>
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
:value="audio.volume.value"
|
||||
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
|
||||
class="speaker-volume-range"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<span class="speaker-volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="tts.speaking.value"
|
||||
class="speaker-row speaker-row--stop"
|
||||
@click="tts.stop()"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
<span>Stop playback</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PTT mic (with live amplitude halo behind) -->
|
||||
<div v-if="voiceEnabled" class="mic-wrapper">
|
||||
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
|
||||
<button
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Abort (streaming) or Send -->
|
||||
<button
|
||||
v-if="store.streaming"
|
||||
class="btn-abort-inline"
|
||||
@click="emit('abort')"
|
||||
title="Stop generation"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-send"
|
||||
@click="onSubmit"
|
||||
:disabled="!messageInput.trim() || !store.chatReady"
|
||||
>↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-input-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attached-note {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.attached-note-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.attached-note-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
.attached-note-remove:hover { color: #fff; }
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
background: var(--color-input-bar-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
}
|
||||
|
||||
.chat-input-bar--pill .input-row {
|
||||
border-radius: 24px;
|
||||
padding: 0.6rem 0.6rem 0.6rem 1rem;
|
||||
}
|
||||
|
||||
.input-textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-input-bar-text);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.input-textarea::placeholder { color: var(--color-input-bar-placeholder); }
|
||||
.input-textarea:disabled { opacity: 0.5; }
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-input-bar-text);
|
||||
opacity: 0.6;
|
||||
padding: 0.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover { opacity: 1; }
|
||||
.btn-icon:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.btn-mic.mic-recording {
|
||||
opacity: 1;
|
||||
/* White icon sits on top of the red halo so it stays legible at any
|
||||
pulse size. */
|
||||
color: #fff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.btn-mic.mic-transcribing { opacity: 0.5; }
|
||||
|
||||
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
|
||||
absolutely positioned behind the button, scaled/faded by the
|
||||
computed `micGlowStyle` so the user gets unmistakable feedback
|
||||
that their voice is being picked up while the mic icon itself
|
||||
stays put and readable on top. */
|
||||
.mic-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mic-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(239, 68, 68, 0.9) 0%,
|
||||
rgba(239, 68, 68, 0.6) 55%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
|
||||
rather than stepping. */
|
||||
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.note-picker-wrapper { position: relative; }
|
||||
.note-picker-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 260px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
.note-picker-search {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.note-picker-results { max-height: 180px; overflow-y: auto; }
|
||||
.note-picker-item {
|
||||
padding: 0.4rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.note-picker-item:hover { background: var(--color-bg-secondary); }
|
||||
.note-picker-empty { padding: 0.4rem 0.65rem; color: var(--color-text-muted); font-size: 0.8rem; }
|
||||
|
||||
.btn-send {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
|
||||
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
|
||||
|
||||
.btn-abort-inline {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
|
||||
|
||||
/* Speaker / listen-mode popover */
|
||||
.speaker-wrapper { position: relative; display: inline-flex; flex-shrink: 0; }
|
||||
.btn-speaker.speaker-active { opacity: 1; color: var(--color-primary); }
|
||||
.btn-speaker.speaker-busy { opacity: 1; color: #f59e0b; }
|
||||
.speaker-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
padding: 0.35rem;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.speaker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.speaker-row--toggle { cursor: pointer; }
|
||||
.speaker-row--toggle:hover { background: var(--color-bg-secondary); }
|
||||
.speaker-row--active { color: var(--color-primary); }
|
||||
.speaker-row--stop {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.speaker-row--stop:hover { color: #ef4444; background: var(--color-bg-secondary); }
|
||||
.speaker-row-label { flex: 1; }
|
||||
.speaker-volume-range { flex: 1; min-width: 0; }
|
||||
.speaker-volume-pct {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
.speaker-switch {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-border);
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.speaker-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-card);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.speaker-switch.on { background: var(--color-primary); }
|
||||
.speaker-switch.on::after { transform: translateX(12px); }
|
||||
</style>
|
||||
@@ -1,356 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import BriefingToolStatusRow from "@/components/BriefingToolStatusRow.vue";
|
||||
import type { Message } from "@/types/chat";
|
||||
|
||||
const SLOT_LABELS: Record<string, string> = {
|
||||
compilation: "Full Briefing",
|
||||
morning: "Morning Update",
|
||||
midday: "Midday Update",
|
||||
afternoon: "Afternoon Update",
|
||||
};
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const props = defineProps<{
|
||||
message: Message;
|
||||
isStreaming?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saveAsNote: [messageId: number];
|
||||
}>();
|
||||
|
||||
const rendered = computed(() => renderMarkdown(props.message.content));
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (!props.message.created_at) return "";
|
||||
const date = new Date(props.message.created_at);
|
||||
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
});
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
switch (props.message.role) {
|
||||
case "user":
|
||||
return "You";
|
||||
case "assistant":
|
||||
return settingsStore.assistantName;
|
||||
default:
|
||||
return props.message.role;
|
||||
}
|
||||
});
|
||||
|
||||
function formatMs(ms: number | null | undefined): string {
|
||||
if (ms == null) return "";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
|
||||
|
||||
const isBriefingIntermediate = computed(
|
||||
() => props.message.role === "assistant" && metadata.value.briefing_intermediate === true,
|
||||
);
|
||||
|
||||
const briefingSlot = computed(() => {
|
||||
const slot = metadata.value.briefing_slot;
|
||||
return typeof slot === "string" ? slot : null;
|
||||
});
|
||||
|
||||
const slotLabel = computed(() => {
|
||||
const slot = briefingSlot.value;
|
||||
if (!slot) return null;
|
||||
return SLOT_LABELS[slot] ?? slot;
|
||||
});
|
||||
|
||||
const isSlotUpdate = computed(
|
||||
() =>
|
||||
!isBriefingIntermediate.value &&
|
||||
briefingSlot.value != null &&
|
||||
briefingSlot.value !== "compilation",
|
||||
);
|
||||
|
||||
const timingParts = computed((): string[] => {
|
||||
const t = props.message.timing;
|
||||
if (!t) return [];
|
||||
const parts: string[] = [];
|
||||
if (t.total_ms != null) parts.push(`${formatMs(t.total_ms)} total`);
|
||||
if (t.ttft_ms != null) parts.push(`first token ${formatMs(t.ttft_ms)}`);
|
||||
if (t.intent_ms != null) parts.push(`analyzed ${formatMs(t.intent_ms)}`);
|
||||
for (const tool of t.tools ?? []) {
|
||||
parts.push(`${tool.name.replace(/_/g, " ")} ${formatMs(tool.ms)}`);
|
||||
}
|
||||
if (t.generation_ms != null) parts.push(`generated ${formatMs(t.generation_ms)}`);
|
||||
return parts;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Briefing intermediate: compact status row, no bubble -->
|
||||
<div v-if="isBriefingIntermediate" class="chat-message role-assistant briefing-intermediate-row">
|
||||
<BriefingToolStatusRow :tool-calls="message.tool_calls ?? []" />
|
||||
</div>
|
||||
<div v-else class="chat-message" :class="[`role-${message.role}`, { 'slot-update': isSlotUpdate }]">
|
||||
<div class="message-group">
|
||||
<div class="message-bubble" :class="{ 'bubble-slot': isSlotUpdate }">
|
||||
<div class="message-header">
|
||||
<span class="role-label">{{ roleLabel }}</span>
|
||||
<span v-if="slotLabel" class="slot-badge" :class="{ 'slot-badge-update': isSlotUpdate }">{{ slotLabel }}</span>
|
||||
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
|
||||
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
|
||||
Save as Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<details v-if="message.thinking" class="thinking-block">
|
||||
<summary class="thinking-summary">Reasoning</summary>
|
||||
<pre class="thinking-text">{{ message.thinking }}</pre>
|
||||
</details>
|
||||
<div class="message-content prose" v-html="rendered"></div>
|
||||
<div v-if="message.tool_calls?.length" class="tool-calls">
|
||||
<ToolCallCard
|
||||
v-for="(tc, i) in message.tool_calls"
|
||||
:key="i"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.context_note_id"
|
||||
class="context-badge"
|
||||
>
|
||||
<router-link :to="`/notes/${message.context_note_id}`">
|
||||
{{ message.context_note_title || `Note #${message.context_note_id}` }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="formattedTime" class="message-timestamp">{{ formattedTime }}</span>
|
||||
<div v-if="timingParts.length" class="message-timing">
|
||||
<span class="timing-icon">⏱</span>
|
||||
<span v-for="(part, i) in timingParts" :key="i" class="timing-part">
|
||||
<span v-if="i > 0" class="timing-sep">·</span>{{ part }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-message {
|
||||
display: flex;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.role-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.role-assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-group {
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
/* User prompts: recessed, muted — margin notes */
|
||||
.role-user .message-bubble {
|
||||
background: var(--color-bubble-user-bg);
|
||||
border: 1px solid var(--color-bubble-user-border);
|
||||
color: var(--color-bubble-user-text);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* Assistant responses: elevated, lit — the primary text */
|
||||
.role-assistant .message-bubble {
|
||||
background: var(--color-bg-card);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
box-shadow: var(--color-bubble-asst-shadow);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.role-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.role-user .role-label {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.role-assistant .role-label {
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border-left: 2px solid rgba(129, 140, 248, 0.35);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thinking-summary {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.thinking-summary::-webkit-details-marker { display: none; }
|
||||
.thinking-summary::before {
|
||||
content: "▶";
|
||||
font-size: 0.6rem;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
details[open] .thinking-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.thinking-text {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.message-content {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
.message-content :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-save {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-save:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.tool-calls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.context-badge {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.context-badge a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.role-user .context-badge a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.context-badge a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-timestamp {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 0.15rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.message-timing {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.1rem 0.5rem 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.timing-icon {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.timing-part {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timing-sep {
|
||||
margin-right: 0.2rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Briefing intermediate row (no bubble) ──────────────── */
|
||||
.briefing-intermediate-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Slot badge (morning/midday/afternoon/full) ─────────── */
|
||||
.slot-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.slot-badge-update {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* ── Slot update bubbles: softer treatment than compilation ── */
|
||||
.slot-update .message-bubble.bubble-slot {
|
||||
background: transparent;
|
||||
border-left: 2px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.slot-update .message-bubble.bubble-slot .message-content {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,955 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import ChatMessage from '@/components/ChatMessage.vue'
|
||||
import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
|
||||
import ChatInputBar from '@/components/ChatInputBar.vue'
|
||||
import ToolCallCard from '@/components/ToolCallCard.vue'
|
||||
import type { Message, ToolCallRecord } from '@/types/chat'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant: 'full' | 'widget'
|
||||
/** Workspace: pins RAG scope and workspace_project_id */
|
||||
projectId?: number
|
||||
/** Briefing: hides scope chip, hides note picker */
|
||||
briefingMode?: boolean
|
||||
/** Hides input bar — for read-only historical views */
|
||||
readOnly?: boolean
|
||||
placeholder?: string
|
||||
autoFocus?: boolean
|
||||
}>(), {
|
||||
briefingMode: false,
|
||||
readOnly: false,
|
||||
autoFocus: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Widget only: emitted when a new conversation is created */
|
||||
'conversation-started': [convId: number]
|
||||
}>()
|
||||
|
||||
const store = useChatStore()
|
||||
|
||||
// ── Scroll ────────────────────────────────────────────────────────────────────
|
||||
const messagesEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesEl.value) {
|
||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => store.streamingContent, () => scrollToBottom())
|
||||
watch(() => store.currentConversation?.messages.length, () => scrollToBottom())
|
||||
|
||||
// Notify CalendarView when an event is created/updated/deleted via tool
|
||||
const _calendarToolNames = new Set(['create_event', 'update_event', 'delete_event'])
|
||||
const _seenCalendarToolIdx = new Set<number>()
|
||||
watch(() => store.streamingToolCalls, (calls) => {
|
||||
calls.forEach((tc, i) => {
|
||||
if (!_seenCalendarToolIdx.has(i) && _calendarToolNames.has(tc.function) && tc.status === 'success') {
|
||||
_seenCalendarToolIdx.add(i)
|
||||
document.dispatchEvent(new CustomEvent('fable:calendar-changed'))
|
||||
}
|
||||
})
|
||||
}, { deep: true })
|
||||
watch(() => store.streaming, (s) => { if (!s) _seenCalendarToolIdx.clear() })
|
||||
|
||||
// ── Note context (full variant — included / suggested / auto-injected notes) ──
|
||||
const includedNoteIds = ref<Set<number>>(new Set())
|
||||
const includedNotes = ref<{ id: number; title: string }[]>([])
|
||||
const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([])
|
||||
const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([])
|
||||
const excludedNoteIds = ref<number[]>([])
|
||||
|
||||
watch(
|
||||
() => store.lastContextMeta,
|
||||
(meta) => {
|
||||
if (!meta || props.variant !== 'full') return
|
||||
const alreadyIncluded = includedNoteIds.value
|
||||
const alreadyAutoInjected = new Set(autoInjectedNotes.value.map((n) => n.id))
|
||||
const alreadySuggested = new Set(suggestedNotes.value.map((n) => n.id))
|
||||
|
||||
for (const note of meta.auto_injected_notes ?? []) {
|
||||
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
||||
autoInjectedNotes.value.push(note)
|
||||
alreadyAutoInjected.add(note.id)
|
||||
}
|
||||
}
|
||||
for (const note of meta.auto_notes ?? []) {
|
||||
if (note.auto_injected) {
|
||||
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
||||
autoInjectedNotes.value.push(note)
|
||||
alreadyAutoInjected.add(note.id)
|
||||
}
|
||||
} else {
|
||||
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id) && !alreadyAutoInjected.has(note.id)) {
|
||||
suggestedNotes.value.push(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function includeNote(note: { id: number; title: string }) {
|
||||
if (includedNoteIds.value.has(note.id)) return
|
||||
includedNoteIds.value = new Set([...includedNoteIds.value, note.id])
|
||||
includedNotes.value.push(note)
|
||||
suggestedNotes.value = suggestedNotes.value.filter((n) => n.id !== note.id)
|
||||
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== note.id)
|
||||
}
|
||||
|
||||
function excludeAutoNote(noteId: number) {
|
||||
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== noteId)
|
||||
if (!excludedNoteIds.value.includes(noteId)) {
|
||||
excludedNoteIds.value = [...excludedNoteIds.value, noteId]
|
||||
}
|
||||
}
|
||||
|
||||
function removeIncludedNote(noteId: number) {
|
||||
includedNoteIds.value = new Set([...includedNoteIds.value].filter((id) => id !== noteId))
|
||||
const removed = includedNotes.value.find((n) => n.id === noteId)
|
||||
includedNotes.value = includedNotes.value.filter((n) => n.id !== noteId)
|
||||
if (removed && !suggestedNotes.value.some((n) => n.id === noteId)) {
|
||||
suggestedNotes.value.push(removed)
|
||||
}
|
||||
}
|
||||
|
||||
const contextCount = computed(
|
||||
() => autoInjectedNotes.value.length + suggestedNotes.value.length + includedNotes.value.length
|
||||
)
|
||||
|
||||
const hasContextData = computed(
|
||||
() => props.variant === 'full' && !props.briefingMode && !props.projectId && contextCount.value > 0
|
||||
)
|
||||
|
||||
// ── Narrow-viewport sidebar toggle ────────────────────────────────────────────
|
||||
const NARROW_BREAKPOINT = 1200
|
||||
const isNarrow = ref(typeof window !== 'undefined' && window.innerWidth < NARROW_BREAKPOINT)
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
function onResize() {
|
||||
isNarrow.value = window.innerWidth < NARROW_BREAKPOINT
|
||||
}
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
function toggleContextSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const hasContextSidebar = computed(() => hasContextData.value && sidebarOpen.value)
|
||||
|
||||
// ── Collapsible sections (per-conversation, localStorage) ─────────────────────
|
||||
type SectionKey = 'auto' | 'suggested' | 'included'
|
||||
const collapsedSections = ref<Set<SectionKey>>(new Set())
|
||||
|
||||
function storageKey(): string | null {
|
||||
const id = store.currentConversation?.id
|
||||
return id ? `fa_chat_ctx_collapsed_${id}` : null
|
||||
}
|
||||
|
||||
function loadCollapsed() {
|
||||
const key = storageKey()
|
||||
if (!key) { collapsedSections.value = new Set(); return }
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) { collapsedSections.value = new Set(); return }
|
||||
const arr = JSON.parse(raw) as SectionKey[]
|
||||
collapsedSections.value = new Set(arr)
|
||||
} catch {
|
||||
collapsedSections.value = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsed() {
|
||||
const key = storageKey()
|
||||
if (!key) return
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify([...collapsedSections.value]))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function toggleSection(key: SectionKey) {
|
||||
const next = new Set(collapsedSections.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
collapsedSections.value = next
|
||||
saveCollapsed()
|
||||
}
|
||||
|
||||
watch(() => store.currentConversation?.id, () => loadCollapsed(), { immediate: true })
|
||||
|
||||
// ── Empty-state (full variant, fresh/empty conversation) ─────────────────────
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
props.variant === 'full'
|
||||
&& !props.briefingMode
|
||||
&& !props.projectId
|
||||
&& !store.streaming
|
||||
&& (store.currentConversation?.messages.length ?? 0) === 0
|
||||
)
|
||||
|
||||
const recentConversations = computed(() => {
|
||||
const currentId = store.currentConversation?.id
|
||||
return store.conversations
|
||||
.filter((c) => c.id !== currentId && c.message_count > 0)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
function startVoiceMode() {
|
||||
window.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
||||
}
|
||||
|
||||
// ── Send ──────────────────────────────────────────────────────────────────────
|
||||
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null)
|
||||
|
||||
async function onSubmit(payload: { content: string; contextNoteId?: number }) {
|
||||
if (props.variant === 'widget') {
|
||||
await widgetSend(payload)
|
||||
return
|
||||
}
|
||||
if (!store.currentConversation) return
|
||||
await store.sendMessage(
|
||||
payload.content,
|
||||
payload.contextNoteId,
|
||||
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
|
||||
false,
|
||||
undefined,
|
||||
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
|
||||
props.projectId ?? store.ragProjectId,
|
||||
props.projectId ?? undefined,
|
||||
)
|
||||
scrollToBottom()
|
||||
nextTick(() => inputBarRef.value?.focus())
|
||||
}
|
||||
|
||||
// ── Widget state ──────────────────────────────────────────────────────────────
|
||||
const widgetConvId = ref<number | null>(null)
|
||||
const widgetQuery = ref('')
|
||||
const widgetDone = ref(false)
|
||||
const widgetFinalContent = ref('')
|
||||
const widgetFinalToolCalls = ref<ToolCallRecord[]>([])
|
||||
|
||||
const isConversational = computed(
|
||||
() => widgetDone.value && widgetFinalToolCalls.value.length === 0
|
||||
)
|
||||
|
||||
function clearWidget() {
|
||||
widgetConvId.value = null
|
||||
widgetDone.value = false
|
||||
widgetQuery.value = ''
|
||||
widgetFinalContent.value = ''
|
||||
widgetFinalToolCalls.value = []
|
||||
}
|
||||
|
||||
async function widgetSend(payload: { content: string; contextNoteId?: number }) {
|
||||
widgetConvId.value = null
|
||||
widgetDone.value = false
|
||||
widgetFinalContent.value = ''
|
||||
widgetFinalToolCalls.value = []
|
||||
widgetQuery.value = payload.content
|
||||
|
||||
const conv = await store.createConversation()
|
||||
await store.fetchConversation(conv.id)
|
||||
widgetConvId.value = conv.id
|
||||
emit('conversation-started', conv.id)
|
||||
|
||||
await store.sendMessage(payload.content, payload.contextNoteId, undefined, false)
|
||||
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === 'assistant')
|
||||
widgetFinalContent.value = lastAssistant?.content ?? ''
|
||||
widgetFinalToolCalls.value = (lastAssistant?.tool_calls ?? []) as ToolCallRecord[]
|
||||
widgetDone.value = true
|
||||
}
|
||||
|
||||
// ── Save as note ──────────────────────────────────────────────────────────────
|
||||
async function handleSaveAsNote(messageId: number) {
|
||||
try {
|
||||
await store.saveMessageAsNote(messageId)
|
||||
const { useToastStore } = await import('@/stores/toast')
|
||||
useToastStore().show('Saved as note')
|
||||
} catch {
|
||||
const { useToastStore } = await import('@/stores/toast')
|
||||
useToastStore().show('Failed to save as note', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
if (props.autoFocus) nextTick(() => inputBarRef.value?.focus())
|
||||
})
|
||||
|
||||
// ── Exposed ───────────────────────────────────────────────────────────────────
|
||||
function focus() {
|
||||
inputBarRef.value?.focus()
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
inputBarRef.value?.prefill(text)
|
||||
}
|
||||
|
||||
async function send(text: string) {
|
||||
await onSubmit({ content: text })
|
||||
}
|
||||
|
||||
// ── Briefing slot separator helper ────────────────────────────────────────────
|
||||
function hasEarlierBriefingSlot(index: number): boolean {
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
for (let i = 0; i < index; i++) {
|
||||
const m = msgs[i]
|
||||
const meta = m?.metadata as Record<string, unknown> | null | undefined
|
||||
if (
|
||||
m?.role === 'assistant' &&
|
||||
meta?.briefing_slot != null &&
|
||||
!meta?.briefing_intermediate
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSidebar, hasContextData })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ═══════════════════════════════ FULL VARIANT ══════════════════════════════ -->
|
||||
<template v-if="variant === 'full'">
|
||||
<div class="chat-full">
|
||||
<!-- Message list -->
|
||||
<div ref="messagesEl" class="messages-container">
|
||||
<div class="messages-inner">
|
||||
<template
|
||||
v-for="(msg, index) in store.currentConversation?.messages ?? []"
|
||||
:key="msg.id"
|
||||
>
|
||||
<!-- Briefing slot separator: before any non-first slot message (skip intermediate tool-call rows) -->
|
||||
<div
|
||||
v-if="briefingMode && msg.role === 'assistant' && msg.metadata?.briefing_slot && !msg.metadata?.briefing_intermediate && hasEarlierBriefingSlot(index)"
|
||||
class="briefing-slot-separator"
|
||||
></div>
|
||||
<ChatMessage
|
||||
:message="msg"
|
||||
@save-as-note="handleSaveAsNote"
|
||||
/>
|
||||
</template>
|
||||
<!-- Streaming bubble -->
|
||||
<ChatStreamingBubble v-if="store.streaming" />
|
||||
<!-- Queued messages -->
|
||||
<template v-if="store.queuedMessages.length">
|
||||
<div
|
||||
v-for="(q, i) in store.queuedMessages"
|
||||
:key="`queued-${i}`"
|
||||
class="chat-message role-user queued-message"
|
||||
>
|
||||
<div class="message-bubble queued-bubble">
|
||||
<div class="queued-badge">Queued</div>
|
||||
<div class="message-content">{{ q.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queued-clear-row">
|
||||
<button class="queued-clear-btn" @click="store.clearQueue()">
|
||||
Cancel {{ store.queuedMessages.length }} queued
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="showEmptyState" class="chat-empty-state">
|
||||
<h2 class="empty-greeting">What's on your mind?</h2>
|
||||
<div v-if="recentConversations.length" class="empty-recent">
|
||||
<div class="empty-section-label">Jump back in</div>
|
||||
<router-link
|
||||
v-for="conv in recentConversations"
|
||||
:key="conv.id"
|
||||
:to="`/chat/${conv.id}`"
|
||||
class="empty-recent-item"
|
||||
>
|
||||
<span class="empty-recent-title">{{ conv.title || 'Untitled conversation' }}</span>
|
||||
<span class="empty-recent-count">{{ conv.message_count }} msg</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<button class="empty-voice-btn" @click="startVoiceMode" title="Start in voice mode">
|
||||
<span class="voice-icon">🎤</span>
|
||||
<span>Start in voice mode</span>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="!store.currentConversation?.messages.length && !store.streaming"
|
||||
class="empty-msg"
|
||||
>Start a conversation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context sidebar (full, non-briefing, non-workspace) -->
|
||||
<aside v-if="hasContextSidebar" class="context-sidebar">
|
||||
<template v-if="autoInjectedNotes.length">
|
||||
<button
|
||||
class="context-sidebar-header"
|
||||
:class="{ collapsed: collapsedSections.has('auto') }"
|
||||
@click="toggleSection('auto')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<span>Auto-included</span>
|
||||
<span class="section-count">{{ autoInjectedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('auto')">
|
||||
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
|
||||
<span class="auto-pill" title="Auto-included by relevance">AUTO</span>
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="suggestedNotes.length">
|
||||
<button
|
||||
class="context-sidebar-header"
|
||||
:class="{ collapsed: collapsedSections.has('suggested'), 'context-sidebar-header-gap': autoInjectedNotes.length }"
|
||||
@click="toggleSection('suggested')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<span>Suggested</span>
|
||||
<span class="section-count">{{ suggestedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('suggested')">
|
||||
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="includedNotes.length">
|
||||
<button
|
||||
class="context-sidebar-header"
|
||||
:class="{ collapsed: collapsedSections.has('included'), 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }"
|
||||
@click="toggleSection('included')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<span>In Context</span>
|
||||
<span class="section-count">{{ includedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('included')">
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<!-- Input area (hidden when readOnly) -->
|
||||
<div v-if="!readOnly" class="input-wrapper">
|
||||
<!-- Unified input bar -->
|
||||
<ChatInputBar
|
||||
ref="inputBarRef"
|
||||
:placeholder="placeholder"
|
||||
:briefing-mode="briefingMode"
|
||||
@submit="onSubmit"
|
||||
@abort="store.cancelGeneration()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ══════════════════════════════ WIDGET VARIANT ══════════════════════════════ -->
|
||||
<template v-else>
|
||||
<ChatInputBar
|
||||
ref="inputBarRef"
|
||||
pill
|
||||
:placeholder="placeholder ?? 'Start a new chat… (Enter to send)'"
|
||||
:briefing-mode="false"
|
||||
@submit="onSubmit"
|
||||
@abort="store.cancelGeneration()"
|
||||
/>
|
||||
|
||||
<!-- Inline response area -->
|
||||
<div v-if="widgetConvId" class="widget-response">
|
||||
<div class="widget-query">{{ widgetQuery }}</div>
|
||||
|
||||
<!-- Tool calls -->
|
||||
<div v-if="store.streaming && store.streamingToolCalls.length" class="widget-tool-calls">
|
||||
<ToolCallCard v-for="(tc, i) in store.streamingToolCalls" :key="i" :tool-call="tc" />
|
||||
</div>
|
||||
<div v-else-if="widgetDone && widgetFinalToolCalls.length" class="widget-tool-calls">
|
||||
<ToolCallCard v-for="(tc, i) in widgetFinalToolCalls" :key="i" :tool-call="tc" />
|
||||
</div>
|
||||
|
||||
<!-- Streaming / final text -->
|
||||
<div v-if="store.streaming" class="widget-text streaming">
|
||||
<div v-if="store.streamingStatus && !store.streamingContent" class="widget-status">
|
||||
<span class="widget-status-dot"></span>{{ store.streamingStatus }}
|
||||
</div>
|
||||
<span v-else-if="store.streamingContent">{{ store.streamingContent }}</span>
|
||||
<span v-else class="thinking-dots">...</span>
|
||||
</div>
|
||||
<div v-else-if="widgetDone && widgetFinalContent" class="widget-text">
|
||||
{{ widgetFinalContent }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="widget-actions" :class="{ conversational: isConversational }">
|
||||
<router-link
|
||||
:to="`/chat/${widgetConvId}`"
|
||||
class="btn-open-chat"
|
||||
:class="{ prominent: isConversational }"
|
||||
>{{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }}</router-link>
|
||||
<button class="btn-clear-response" @click="clearWidget">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Full variant layout ──
|
||||
* Single grid owns the reading column + context sidebar + input bar so
|
||||
* messages and input bar share one centered reading track while the
|
||||
* context sidebar spans the full height from header to bottom of view.
|
||||
* Reading column is always centered (3-column grid: 1fr | content | 1fr).
|
||||
* The context sidebar overlays the right gutter so it never shifts layout.
|
||||
*/
|
||||
.chat-full {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
1fr
|
||||
minmax(0, var(--chat-reading-width))
|
||||
1fr;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
}
|
||||
.messages-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Briefing slot separator */
|
||||
.briefing-slot-separator {
|
||||
height: 1px;
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
background: var(--color-border, #333);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Context sidebar — overlays right gutter, never shifts reading column */
|
||||
.context-sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--chat-context-sidebar-width);
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: 0.75rem 0.5rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.78rem;
|
||||
z-index: 2;
|
||||
}
|
||||
.context-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.15rem 0.1rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.context-sidebar-header:hover { color: var(--color-text); }
|
||||
.context-sidebar-header .chev {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.context-sidebar-header.collapsed .chev {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.context-sidebar-header .section-count {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.context-sidebar-header-gap { margin-top: 0.75rem; }
|
||||
.context-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.2rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.context-note-auto {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 8%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.context-note-included {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
}
|
||||
.auto-pill {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.context-note-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.context-note-name:hover { color: var(--color-primary); }
|
||||
.context-note-score {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.score-high { color: #22c55e; }
|
||||
.score-medium { color: #f59e0b; }
|
||||
.score-low { color: var(--color-text-muted); }
|
||||
.context-note-remove, .context-note-add {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.context-note-remove:hover { color: #ef4444; }
|
||||
.context-note-add:hover { color: var(--color-primary); }
|
||||
|
||||
/* Input wrapper — lives in the same reading column as messages */
|
||||
.input-wrapper {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Queued messages */
|
||||
.queued-message { opacity: 0.45; }
|
||||
.queued-bubble {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.queued-badge {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.queued-clear-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.queued-clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
}
|
||||
|
||||
.chat-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1.5rem;
|
||||
padding: 3rem 1rem 2rem;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-greeting {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-recent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: var(--radius-md, 10px);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated, rgba(255, 255, 255, 0.02));
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.empty-recent-item + .empty-recent-item {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.empty-recent-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.empty-recent-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-recent-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-voice-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.empty-voice-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.empty-voice-btn .voice-icon {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* ── Widget variant ── */
|
||||
.widget-response {
|
||||
margin-top: 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.widget-query {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.widget-text {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.widget-text.streaming { color: var(--color-text-muted); }
|
||||
.thinking-dots { color: var(--color-text-muted); letter-spacing: 0.2em; }
|
||||
.widget-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.widget-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
animation: blink 1s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes blink { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
|
||||
.widget-tool-calls { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.widget-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-open-chat {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-open-chat:hover { color: var(--color-primary); }
|
||||
.btn-open-chat.prominent {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-clear-response {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
.btn-clear-response:hover { color: var(--color-text); }
|
||||
|
||||
/* Streaming bubble styles — applied via :deep to ChatStreamingBubble children */
|
||||
:deep(.streaming-bubble) {
|
||||
max-width: 85%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
background: var(--color-bg-card);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
box-shadow: 0 1px 4px var(--color-shadow);
|
||||
}
|
||||
:deep(.streaming-tool-calls) { margin-bottom: 0.5rem; }
|
||||
:deep(.streaming-status-line) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
:deep(.streaming-status-dot) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
animation: blink 1s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
:deep(.thinking-block) {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.thinking-summary) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
background: var(--color-bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
:deep(.thinking-summary::-webkit-details-marker) { display: none; }
|
||||
:deep(.thinking-text) {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
:deep(.typing-indicator) {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
animation: blink 1s infinite;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import ToolCallCard from '@/components/ToolCallCard.vue'
|
||||
import ToolConfirmCard from '@/components/ToolConfirmCard.vue'
|
||||
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const streamingRendered = computed(() => {
|
||||
if (!store.streamingContent) return ''
|
||||
return renderMarkdown(store.streamingContent)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-message role-assistant">
|
||||
<div class="message-bubble streaming-bubble">
|
||||
<div class="message-header">
|
||||
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
||||
</div>
|
||||
<div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
|
||||
<ToolCallCard
|
||||
v-for="(tc, i) in store.streamingToolCalls"
|
||||
:key="i"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
<ToolConfirmCard
|
||||
v-if="store.streamingPendingTool"
|
||||
:pending-tool="store.streamingPendingTool"
|
||||
@accept="store.confirmTool(true)"
|
||||
@decline="store.confirmTool(false)"
|
||||
/>
|
||||
<div v-if="store.streamingStatus" class="streaming-status-line">
|
||||
<span class="streaming-status-dot"></span>
|
||||
{{ store.streamingStatus }}
|
||||
</div>
|
||||
<details
|
||||
v-if="store.streamingThinking"
|
||||
class="thinking-block"
|
||||
:open="!store.streamingContent"
|
||||
>
|
||||
<summary class="thinking-summary">Reasoning</summary>
|
||||
<pre class="thinking-text">{{ store.streamingThinking }}</pre>
|
||||
</details>
|
||||
<div class="message-content prose" v-html="streamingRendered"></div>
|
||||
<span
|
||||
v-if="!store.streamingStatus && !store.streamingThinking && !store.streamingContent"
|
||||
class="typing-indicator"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -124,7 +124,6 @@ function markerFor(type: DiffLine['type']): string {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
@@ -153,7 +152,6 @@ function markerFor(type: DiffLine['type']): string {
|
||||
.diff-collapse {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
padding-top: 0.2rem;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
@@ -36,6 +37,36 @@ const description = ref("");
|
||||
const location = ref("");
|
||||
const color = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const recurrence = ref<string>("");
|
||||
|
||||
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
|
||||
// through to `recurrence`. CalDAV-imported rules with extra parts
|
||||
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
|
||||
// string is shown read-only below the select.
|
||||
const RECURRENCE_PRESETS: Record<string, string> = {
|
||||
none: "",
|
||||
daily: "FREQ=DAILY",
|
||||
weekly: "FREQ=WEEKLY",
|
||||
monthly: "FREQ=MONTHLY",
|
||||
yearly: "FREQ=YEARLY",
|
||||
};
|
||||
|
||||
const recurrencePreset = computed<string>({
|
||||
get() {
|
||||
const r = (recurrence.value || "").trim();
|
||||
if (!r) return "none";
|
||||
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
|
||||
if (val && val === r) return key;
|
||||
}
|
||||
return "custom";
|
||||
},
|
||||
set(key: string) {
|
||||
if (key === "custom") return; // no-op; can't pick custom from dropdown
|
||||
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
|
||||
|
||||
function dateFromIso(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
@@ -114,6 +145,7 @@ function resetForm() {
|
||||
location.value = props.event.location || "";
|
||||
color.value = props.event.color || "";
|
||||
projectId.value = props.event.project_id;
|
||||
recurrence.value = props.event.recurrence || "";
|
||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
||||
} else {
|
||||
@@ -129,6 +161,7 @@ function resetForm() {
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
recurrence.value = "";
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
deleteConfirm.value = false;
|
||||
@@ -176,26 +209,69 @@ watch(() => props.event, resetForm, { immediate: true });
|
||||
watch(() => props.initialDate, resetForm);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") emit("close");
|
||||
if (e.key === "Escape") {
|
||||
if (deleteConfirm.value) {
|
||||
// Esc cancels the delete-confirm rather than closing the modal —
|
||||
// gives the user a clear way out of the destructive prompt.
|
||||
deleteConfirm.value = false;
|
||||
return;
|
||||
}
|
||||
attemptClose();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
||||
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
||||
|
||||
async function save() {
|
||||
// ── Close / save flow ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
|
||||
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
|
||||
//
|
||||
// Validity-aware behavior:
|
||||
// - Form valid → save (PATCH for edit, POST for create), then close.
|
||||
// - Form invalid in EDIT mode → discard the in-memory change and close.
|
||||
// A toast tells the user what happened so they don't think their edit
|
||||
// silently landed.
|
||||
// - Form invalid in CREATE mode → close silently (nothing existed to begin
|
||||
// with; no need to call this out).
|
||||
|
||||
function isFormValid(): { valid: boolean; reason?: string } {
|
||||
if (!title.value.trim()) {
|
||||
toast.show("Title is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Title required" };
|
||||
}
|
||||
if (!startDate.value) {
|
||||
toast.show("Start date is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Start date required" };
|
||||
}
|
||||
if (!allDay.value && !startTime.value) {
|
||||
toast.show("Start time is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Start time required" };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
let _closing = false;
|
||||
|
||||
async function attemptClose() {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
try {
|
||||
const validity = isFormValid();
|
||||
if (!validity.valid) {
|
||||
if (isEditMode.value) {
|
||||
toast.show(`${validity.reason} — change discarded`, "warning");
|
||||
}
|
||||
// Create mode + invalid: silent close. Nothing was committed.
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
await save();
|
||||
emit("close");
|
||||
} finally {
|
||||
_closing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
||||
const end_dt = endDate.value
|
||||
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
||||
@@ -213,9 +289,9 @@ async function save() {
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || null,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
toast.show("Event updated", "success");
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
@@ -227,9 +303,9 @@ async function save() {
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
toast.show("Event created", "success");
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
@@ -255,23 +331,57 @@ async function doDelete() {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="slide-over-backdrop" @click.self="emit('close')">
|
||||
<div class="slide-over-panel" role="dialog" aria-modal="true">
|
||||
<div class="so-header">
|
||||
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<button class="so-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||
<div class="modal-backdrop" @click.self="attemptClose">
|
||||
<div class="modal-panel" role="dialog" aria-modal="true">
|
||||
<!-- Header: trash + close (or inline delete-confirm) -->
|
||||
<div class="modal-header">
|
||||
<template v-if="!deleteConfirm">
|
||||
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
class="header-btn header-btn-danger"
|
||||
@click="deleteConfirm = true"
|
||||
title="Delete event"
|
||||
aria-label="Delete event"
|
||||
><Trash2 :size="16" /></button>
|
||||
<button
|
||||
class="header-btn"
|
||||
@click="attemptClose"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
><X :size="16" /></button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-prompt">Delete this event?</span>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="deleting"
|
||||
@click="doDelete"
|
||||
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-confirm-cancel"
|
||||
@click="deleteConfirm = false"
|
||||
>No</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<form class="so-form" @submit.prevent="save">
|
||||
<!-- Body: form (scrolls if it gets long) -->
|
||||
<form class="modal-form" @submit.prevent="attemptClose">
|
||||
<!-- Title -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="so-input" placeholder="Event title" autofocus />
|
||||
<div class="form-field">
|
||||
<label class="form-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
|
||||
</div>
|
||||
|
||||
<!-- All-day toggle -->
|
||||
<div class="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">All day</label>
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">All day</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', { active: allDay }]"
|
||||
@@ -280,74 +390,73 @@ async function doDelete() {
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Start <span class="required">*</span></label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Start <span class="required">*</span></label>
|
||||
<div class="dt-row">
|
||||
<input v-model="startDate" type="date" class="so-input dt-date" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
|
||||
<input v-model="startDate" type="date" class="form-input dt-date" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
|
||||
</div>
|
||||
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
|
||||
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">End</label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">End</label>
|
||||
<div class="dt-row">
|
||||
<input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
|
||||
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Repeat</label>
|
||||
<select v-model="recurrencePreset" class="form-input">
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
|
||||
</select>
|
||||
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
|
||||
Custom rule: <code>{{ recurrence }}</code>
|
||||
<br />
|
||||
<span class="form-hint">Picking a preset will replace this rule.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Location <span class="so-hint">(optional)</span></label>
|
||||
<input v-model="location" class="so-input" placeholder="Location" />
|
||||
<div class="form-field">
|
||||
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
||||
<input v-model="location" class="form-input" placeholder="Location" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Description <span class="so-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" />
|
||||
<div class="form-field">
|
||||
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">Color</label>
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">Color</label>
|
||||
<div class="color-row">
|
||||
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
||||
<input v-model="color" class="so-input color-hex" placeholder="#7c3aed" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''">✕</button>
|
||||
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Project <span class="so-hint">(optional)</span></label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
|
||||
<ProjectSelector v-model="projectId" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="so-actions">
|
||||
<button type="submit" class="btn-primary" :disabled="saving">
|
||||
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
|
||||
<template v-if="isEditMode">
|
||||
<button
|
||||
v-if="!deleteConfirm"
|
||||
type="button"
|
||||
class="btn-danger-ghost"
|
||||
@click="deleteConfirm = true"
|
||||
>Delete</button>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-label">Delete this event?</span>
|
||||
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
|
||||
{{ deleting ? "Deleting…" : "Yes, delete" }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
|
||||
matching the no-explicit-Save-button intent: Enter commits. -->
|
||||
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,81 +464,110 @@ async function doDelete() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-over-backdrop {
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.slide-over-panel {
|
||||
.modal-panel {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border-left: 1px solid var(--color-border, #2a2b30);
|
||||
width: min(440px, 100vw);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
width: min(480px, 100%);
|
||||
max-height: calc(100vh - 2.5rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.so-header {
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.85rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
z-index: 1;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.so-title {
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.so-close {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--color-hover, rgba(255,255,255,0.06));
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.so-form {
|
||||
padding: 1.25rem 1.5rem;
|
||||
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
|
||||
than Save would have been — destructive actions shouldn't loom. */
|
||||
.header-btn-danger:hover {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inline delete-confirm prompt replaces the title row */
|
||||
.delete-confirm-prompt {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form scrolls inside the panel when content overflows */
|
||||
.modal-form {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.so-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.so-label {
|
||||
.form-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.so-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
.form-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
|
||||
.required { color: #f87171; }
|
||||
|
||||
.so-input {
|
||||
.form-input {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text, #e8e9f0);
|
||||
@@ -440,18 +578,33 @@ async function doDelete() {
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.so-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.form-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
|
||||
.dt-row { display: flex; gap: 0.5rem; }
|
||||
.dt-date { flex: 1; }
|
||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
||||
.so-past-hint {
|
||||
.form-past-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recurrence-custom-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.recurrence-custom-hint code {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
@@ -482,67 +635,43 @@ async function doDelete() {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.so-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2b30);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.btn-danger-ghost {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid #ef4444;
|
||||
color: #ef4444;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger-ghost:hover { background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
/* Confirm-delete buttons (only shown during the inline confirm flow) */
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.delete-confirm-label {
|
||||
.btn-confirm-cancel {
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
align-self: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
|
||||
path as X / Esc / backdrop. No visible Save button needed. */
|
||||
.hidden-submit {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
|
||||
@@ -10,6 +10,8 @@ interface NoteVersion {
|
||||
title: string;
|
||||
tags: string[];
|
||||
body?: string;
|
||||
pin_kind: "auto" | "manual" | null;
|
||||
pin_label: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,69 @@ function restore() {
|
||||
emit('restore', selectedVersion.value.body, selectedVersion.value.tags ?? []);
|
||||
}
|
||||
|
||||
// ── Pin / unpin / edit-label state ──────────────────────────────────────────
|
||||
|
||||
const editingLabel = ref(false);
|
||||
const draftLabel = ref("");
|
||||
const pinSaving = ref(false);
|
||||
|
||||
function startEditLabel() {
|
||||
if (!selectedVersion.value) return;
|
||||
draftLabel.value = selectedVersion.value.pin_label ?? "";
|
||||
editingLabel.value = true;
|
||||
}
|
||||
|
||||
function cancelEditLabel() {
|
||||
editingLabel.value = false;
|
||||
draftLabel.value = "";
|
||||
}
|
||||
|
||||
async function savePin() {
|
||||
if (!selectedVersion.value || pinSaving.value) return;
|
||||
pinSaving.value = true;
|
||||
try {
|
||||
const updated = await pinNoteVersion(
|
||||
props.noteId, selectedVersion.value.id, draftLabel.value || null,
|
||||
);
|
||||
// Reflect server state in the local list + selected version.
|
||||
const idx = versions.value.findIndex(v => v.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
versions.value[idx].pin_kind = updated.pin_kind;
|
||||
versions.value[idx].pin_label = updated.pin_label;
|
||||
}
|
||||
if (selectedVersion.value) {
|
||||
selectedVersion.value.pin_kind = updated.pin_kind;
|
||||
selectedVersion.value.pin_label = updated.pin_label;
|
||||
}
|
||||
editingLabel.value = false;
|
||||
} catch {
|
||||
// silent — leaving the form open lets the user retry
|
||||
} finally {
|
||||
pinSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unpinSelected() {
|
||||
if (!selectedVersion.value || pinSaving.value) return;
|
||||
pinSaving.value = true;
|
||||
try {
|
||||
await unpinNoteVersion(props.noteId, selectedVersion.value.id);
|
||||
const idx = versions.value.findIndex(v => v.id === selectedVersion.value!.id);
|
||||
if (idx >= 0) {
|
||||
versions.value[idx].pin_kind = null;
|
||||
versions.value[idx].pin_label = null;
|
||||
}
|
||||
if (selectedVersion.value) {
|
||||
selectedVersion.value.pin_kind = null;
|
||||
selectedVersion.value.pin_label = null;
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
pinSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadVersions);
|
||||
</script>
|
||||
|
||||
@@ -128,7 +193,25 @@ onMounted(loadVersions);
|
||||
:class="['history-item', { selected: selectedVersion?.id === v.id }]"
|
||||
@click="selectVersionItem(v)"
|
||||
>
|
||||
<div class="history-item-title">{{ v.title || 'Untitled' }}</div>
|
||||
<div class="history-item-title">
|
||||
<span
|
||||
v-if="v.pin_kind === 'manual'"
|
||||
class="pin-badge pin-badge-manual"
|
||||
:title="v.pin_label || 'Pinned'"
|
||||
aria-label="manually pinned"
|
||||
>●</span>
|
||||
<span
|
||||
v-else-if="v.pin_kind === 'auto'"
|
||||
class="pin-badge pin-badge-auto"
|
||||
:title="v.pin_label || 'Auto-pinned (stable)'"
|
||||
aria-label="auto-pinned"
|
||||
>◐</span>
|
||||
{{ v.title || 'Untitled' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="v.pin_kind === 'manual' && v.pin_label"
|
||||
class="history-item-label"
|
||||
>{{ v.pin_label }}</div>
|
||||
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +220,62 @@ onMounted(loadVersions);
|
||||
<div class="history-diff">
|
||||
<div v-if="loadingDetail" class="history-empty">Loading version...</div>
|
||||
<div v-else-if="!selectedVersion" class="history-empty">Select a version to compare.</div>
|
||||
<DiffView v-else :diff="diff" />
|
||||
<template v-else>
|
||||
<div class="version-pin-controls">
|
||||
<!-- Label editor -->
|
||||
<div v-if="editingLabel" class="pin-label-form">
|
||||
<input
|
||||
v-model="draftLabel"
|
||||
maxlength="500"
|
||||
type="text"
|
||||
class="pin-label-input"
|
||||
placeholder="Optional label (e.g. 'post-network-refresh runbook')"
|
||||
/>
|
||||
<button class="btn-pin-save" :disabled="pinSaving" @click="savePin">
|
||||
{{ pinSaving ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button class="btn-pin-cancel" @click="cancelEditLabel">Cancel</button>
|
||||
</div>
|
||||
|
||||
<!-- Default: kind-aware action row -->
|
||||
<div v-else class="pin-actions">
|
||||
<span v-if="selectedVersion.pin_kind === 'manual'" class="pin-state">
|
||||
<span class="pin-badge pin-badge-manual">●</span>
|
||||
Manually pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
||||
</span>
|
||||
<span v-else-if="selectedVersion.pin_kind === 'auto'" class="pin-state">
|
||||
<span class="pin-badge pin-badge-auto">◐</span>
|
||||
Auto-pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="selectedVersion.pin_kind === null"
|
||||
class="btn-pin"
|
||||
:disabled="pinSaving"
|
||||
@click="startEditLabel"
|
||||
>Pin version</button>
|
||||
<button
|
||||
v-else-if="selectedVersion.pin_kind === 'manual'"
|
||||
class="btn-pin-edit"
|
||||
:disabled="pinSaving"
|
||||
@click="startEditLabel"
|
||||
>Edit label</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-pin"
|
||||
:disabled="pinSaving"
|
||||
@click="startEditLabel"
|
||||
>Pin permanently</button>
|
||||
<button
|
||||
v-if="selectedVersion.pin_kind === 'manual'"
|
||||
class="btn-unpin"
|
||||
:disabled="pinSaving"
|
||||
@click="unpinSelected"
|
||||
>Unpin</button>
|
||||
</div>
|
||||
</div>
|
||||
<DiffView :diff="diff" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -244,7 +382,6 @@ onMounted(loadVersions);
|
||||
padding: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history-footer {
|
||||
@@ -269,4 +406,101 @@ onMounted(loadVersions);
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Pin badges + label rendering ───────────────────────────────────────── */
|
||||
.pin-badge {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
text-align: center;
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
.pin-badge-manual { color: var(--color-primary, #6366f1); }
|
||||
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
|
||||
|
||||
.history-item-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-style: italic;
|
||||
margin-top: 0.15rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
||||
.version-pin-controls {
|
||||
padding: 0.4rem 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pin-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pin-state {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-pin, .btn-pin-edit, .btn-unpin {
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-unpin:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.pin-label-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.pin-label-input {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
color: inherit;
|
||||
}
|
||||
.pin-label-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-pin-save, .btn-pin-cancel {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin-save:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -151,7 +151,6 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Review ── */
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
Code,
|
||||
SquareCode,
|
||||
Quote,
|
||||
Link as LinkIcon,
|
||||
Brackets,
|
||||
} from "lucide-vue-next";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const props = defineProps<{ editor: Editor | null }>();
|
||||
|
||||
// SVG icons — Material Design paths (24x24) or custom SVG
|
||||
const ICONS: Record<string, string> = {
|
||||
bold: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>`,
|
||||
italic: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>`,
|
||||
strike: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/></svg>`,
|
||||
h1: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">1</text></svg>`,
|
||||
h2: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">2</text></svg>`,
|
||||
h3: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">3</text></svg>`,
|
||||
ul: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`,
|
||||
ol: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2zm1-9h1V4H2v1h1zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2zm5-3h13V6H7v2zm0 4h13v-2H7v2zm0 4h13v-2H7v2z"/></svg>`,
|
||||
task: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>`,
|
||||
code: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>`,
|
||||
codeblock: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM4 19h16v2H4z"/></svg>`,
|
||||
quote: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>`,
|
||||
link: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`,
|
||||
wikilink: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/></svg>`,
|
||||
const ICONS: Record<string, Component> = {
|
||||
bold: Bold,
|
||||
italic: Italic,
|
||||
strike: Strikethrough,
|
||||
h1: Heading1,
|
||||
h2: Heading2,
|
||||
h3: Heading3,
|
||||
ul: List,
|
||||
ol: ListOrdered,
|
||||
task: ListChecks,
|
||||
code: Code,
|
||||
codeblock: SquareCode,
|
||||
quote: Quote,
|
||||
link: LinkIcon,
|
||||
wikilink: Brackets,
|
||||
};
|
||||
|
||||
const groups = [
|
||||
@@ -81,7 +97,7 @@ const groups = [
|
||||
tabindex="-1"
|
||||
@mousedown.prevent="btn.command()"
|
||||
>
|
||||
<span class="btn-icon" v-html="ICONS[btn.id]" />
|
||||
<component :is="ICONS[btn.id]" class="btn-icon" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="gi < groups.length - 1" class="toolbar-sep" aria-hidden="true" />
|
||||
|
||||
@@ -64,11 +64,11 @@ function goEdit() {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.note-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function goEdit() {
|
||||
}
|
||||
.note-card.compact:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(124, 58, 237, 0.04);
|
||||
background: rgba(91, 74, 138, 0.04);
|
||||
transform: none;
|
||||
}
|
||||
.note-title-compact {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import NotificationsPanel from './NotificationsPanel.vue'
|
||||
import { Bell } from 'lucide-vue-next'
|
||||
|
||||
const store = useNotificationsStore()
|
||||
const open = ref(false)
|
||||
@@ -45,10 +46,7 @@ onUnmounted(() => {
|
||||
aria-label="Notifications"
|
||||
:title="`${store.count} unread notification${store.count !== 1 ? 's' : ''}`"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<Bell :size="16" aria-hidden="true" />
|
||||
<span v-if="store.count > 0" class="bell-badge" aria-live="polite">{{ store.count > 99 ? '99+' : store.count }}</span>
|
||||
</button>
|
||||
<NotificationsPanel v-if="open" @close="close" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
@@ -56,7 +57,7 @@ onMounted(() => store.fetchAll())
|
||||
</p>
|
||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||
</div>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss">✕</button>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notif-empty">No unread notifications</div>
|
||||
@@ -151,6 +152,5 @@ onMounted(() => store.fetchAll())
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
searchUsers,
|
||||
@@ -116,7 +117,7 @@ onMounted(async () => {
|
||||
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
|
||||
<header class="share-header">
|
||||
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
</header>
|
||||
|
||||
<!-- Add share form -->
|
||||
@@ -183,7 +184,7 @@ onMounted(async () => {
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove">✕</button>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
|
||||
</li>
|
||||
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
|
||||
</ul>
|
||||
@@ -409,7 +410,6 @@ onMounted(async () => {
|
||||
.shares-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.task-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,812 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { apiPost, getEvent } from "@/api/client";
|
||||
import type { EventEntry } from "@/api/client";
|
||||
import type { ToolCallRecord } from "@/types/chat";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: ToolCallRecord;
|
||||
}>();
|
||||
|
||||
const appliedTags = ref<Set<string>>(new Set());
|
||||
const applyingTag = ref<string | null>(null);
|
||||
const confirmState = ref<"idle" | "creating" | "created" | "denied">("idle");
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.toolCall.status === "declined") return "Declined";
|
||||
if (props.toolCall.result.requires_confirmation) return "Similar content found";
|
||||
if (!props.toolCall.result.success) return "Error";
|
||||
switch (props.toolCall.result.type) {
|
||||
case "task": return "Created task";
|
||||
case "note": return "Created note";
|
||||
case "note_updated": return "Updated note";
|
||||
case "note_deleted": return "Deleted note";
|
||||
case "task_deleted": return "Deleted task";
|
||||
case "note_content": return "Note";
|
||||
case "notes_list": return "Notes";
|
||||
case "tasks": return "Tasks";
|
||||
case "todo_updated": return "Updated todo";
|
||||
case "search": return "Searched notes";
|
||||
case "event": return "Created event";
|
||||
case "events": return "Found events";
|
||||
case "event_updated": return "Updated event";
|
||||
case "event_deleted": return "Deleted event";
|
||||
case "calendars": return "Calendars";
|
||||
case "todo": return "Created todo";
|
||||
case "todos": return "Calendar todos";
|
||||
case "todo_completed": return "Completed todo";
|
||||
case "todo_deleted": return "Deleted todo";
|
||||
case "web_search": return "Web search";
|
||||
case "research_pending": return "Research started";
|
||||
case "research_note": return "Research note";
|
||||
case "image_search": return "Image search";
|
||||
default: return "Tool call";
|
||||
}
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data) return null;
|
||||
if (typeof data.title === "string") return data.title;
|
||||
return null;
|
||||
});
|
||||
|
||||
const linkTo = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || typeof data.id !== "number") return null;
|
||||
return `/notes/${data.id}`;
|
||||
});
|
||||
|
||||
const noteId = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || typeof data.id !== "number") return null;
|
||||
return data.id;
|
||||
});
|
||||
|
||||
const suggestedTags = computed(() => props.toolCall.result.suggested_tags ?? []);
|
||||
|
||||
const eventData = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "event") return null;
|
||||
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
|
||||
});
|
||||
|
||||
const updatedEvent = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "event_updated") return null;
|
||||
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
|
||||
});
|
||||
|
||||
const deletedEvent = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "event_deleted") return null;
|
||||
return data as { id?: number; title: string };
|
||||
});
|
||||
|
||||
const calendarList = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "calendars") return null;
|
||||
const calendars = data.calendars as Array<{ name: string }> | undefined;
|
||||
return calendars?.map((c) => c.name) ?? [];
|
||||
});
|
||||
|
||||
const todoData = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
const type = props.toolCall.result.type;
|
||||
if (!data || (type !== "todo" && type !== "todo_completed" && type !== "todo_deleted" && type !== "todo_updated")) return null;
|
||||
return data as { summary: string; due?: string; status?: string };
|
||||
});
|
||||
|
||||
const todoList = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "todos") return null;
|
||||
return (data.todos as Array<{ summary: string; due?: string; status?: string }> | undefined) ?? [];
|
||||
});
|
||||
|
||||
const todoCount = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "todos") return 0;
|
||||
return (data.count as number) ?? 0;
|
||||
});
|
||||
|
||||
const eventList = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "events") return null;
|
||||
return (data.events as Array<{ id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string }> | undefined) ?? [];
|
||||
});
|
||||
|
||||
const eventCount = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "events") return 0;
|
||||
return (data.count as number) ?? 0;
|
||||
});
|
||||
|
||||
function formatEventTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
weekday: "short", month: "short", day: "numeric",
|
||||
hour: "numeric", minute: "2-digit",
|
||||
});
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
const taskList = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "tasks") return null;
|
||||
return (data.results as Array<{ id: number; title: string; status: string; priority: string; due_date?: string }> | undefined) ?? [];
|
||||
});
|
||||
|
||||
const taskCount = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "tasks") return 0;
|
||||
return (data.total as number) ?? 0;
|
||||
});
|
||||
|
||||
const webSearch = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "web_search") return null;
|
||||
return data as { query: string; results: { url: string; title: string; snippet: string }[]; count: number };
|
||||
});
|
||||
|
||||
const searchResults = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "search") return null;
|
||||
const results = data.results as Array<{ id: number; title: string; type: string }> | undefined;
|
||||
return results && results.length > 0 ? results : null;
|
||||
});
|
||||
|
||||
const deletedNote = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
const type = props.toolCall.result.type;
|
||||
if (!data || (type !== "note_deleted" && type !== "task_deleted")) return null;
|
||||
return data as { id: number; title: string };
|
||||
});
|
||||
|
||||
const noteContent = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "note_content") return null;
|
||||
return data as { id: number; title: string; body: string; tags: string[] };
|
||||
});
|
||||
|
||||
const noteList = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "notes_list") return null;
|
||||
return (data.results as Array<{ id: number; title: string; tags: string[]; preview: string }> | undefined) ?? [];
|
||||
});
|
||||
|
||||
const noteListCount = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "notes_list") return 0;
|
||||
return (data.total as number) ?? 0;
|
||||
});
|
||||
|
||||
// ── Collapse logic ───────────────────────────────────────────────────────────
|
||||
|
||||
// Cards with rich expandable content
|
||||
const hasDetail = computed(() => {
|
||||
if (props.toolCall.status === "running") return false;
|
||||
return (
|
||||
(noteContent.value !== null && noteContent.value.tags.length > 0) ||
|
||||
(noteList.value !== null && noteList.value.length > 0) ||
|
||||
searchResults.value !== null ||
|
||||
(taskList.value !== null && taskList.value.length > 0) ||
|
||||
(eventList.value !== null && eventList.value.length > 0) ||
|
||||
(todoList.value !== null && todoList.value.length > 0) ||
|
||||
webSearch.value !== null ||
|
||||
(calendarList.value !== null && calendarList.value.length > 0) ||
|
||||
suggestedTags.value.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
// Collapsed by default for completed calls; errors and running stay open
|
||||
const collapsed = ref(
|
||||
props.toolCall.status === "success" || props.toolCall.status === "declined"
|
||||
);
|
||||
|
||||
// Auto-collapse when a running tool call completes
|
||||
watch(() => props.toolCall.status, (s) => {
|
||||
if (s === "success") collapsed.value = true;
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
if (hasDetail.value) collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
async function confirmDuplicate() {
|
||||
if (confirmState.value !== "idle") return;
|
||||
confirmState.value = "creating";
|
||||
const args = props.toolCall.arguments as Record<string, unknown>;
|
||||
const isTask = !!(args.status);
|
||||
const endpoint = isTask ? "/api/tasks" : "/api/notes";
|
||||
const payload: Record<string, unknown> = {
|
||||
title: args.title,
|
||||
body: args.body ?? "",
|
||||
tags: args.tags ?? [],
|
||||
...(args.due_date ? { due_date: args.due_date } : {}),
|
||||
...(args.priority ? { priority: args.priority } : {}),
|
||||
...(args.project ? { project: args.project } : {}),
|
||||
...(isTask ? { status: args.status ?? "todo" } : {}),
|
||||
};
|
||||
try {
|
||||
await apiPost(endpoint, payload);
|
||||
confirmState.value = "created";
|
||||
} catch {
|
||||
confirmState.value = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
function denyDuplicate() {
|
||||
confirmState.value = "denied";
|
||||
}
|
||||
|
||||
async function applyTag(tag: string) {
|
||||
if (!noteId.value || appliedTags.value.has(tag) || applyingTag.value) return;
|
||||
applyingTag.value = tag;
|
||||
try {
|
||||
await apiPost(`/api/notes/${noteId.value}/append-tag`, { tag });
|
||||
appliedTags.value.add(tag);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
applyingTag.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event slide-over ─────────────────────────────────────────────────────────
|
||||
|
||||
const eventSlideOverOpen = ref(false);
|
||||
const eventSlideOverEntry = ref<EventEntry | null>(null);
|
||||
|
||||
async function openEventSlideOver(id: number | undefined) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const entry = await getEvent(id);
|
||||
eventSlideOverEntry.value = entry;
|
||||
eventSlideOverOpen.value = true;
|
||||
} catch {
|
||||
// silently fail — event may have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
function closeEventSlideOver(changed = false) {
|
||||
eventSlideOverOpen.value = false;
|
||||
if (changed) {
|
||||
document.dispatchEvent(new Event("fable:calendar-changed"));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="tool-call-card"
|
||||
:class="{
|
||||
error: toolCall.status === 'error' && !toolCall.result.requires_confirmation,
|
||||
'requires-confirm': toolCall.result.requires_confirmation,
|
||||
declined: toolCall.status === 'declined',
|
||||
running: toolCall.status === 'running',
|
||||
collapsible: hasDetail,
|
||||
}"
|
||||
>
|
||||
<!-- ── Header row (always visible) ─────────────────────────── -->
|
||||
<div class="tool-card-header" :class="{ clickable: hasDetail }" @click="toggle">
|
||||
<span class="tool-label">{{ label }}</span>
|
||||
|
||||
<!-- Inline summary content -->
|
||||
<template v-if="toolCall.status === 'declined'">
|
||||
<span class="tool-declined-name">
|
||||
{{ toolCall.arguments.title ?? toolCall.arguments.summary ?? toolCall.arguments.query ?? toolCall.function }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="toolCall.result.requires_confirmation">
|
||||
<router-link
|
||||
v-if="toolCall.result.similar_note"
|
||||
:to="`/notes/${toolCall.result.similar_note.id}`"
|
||||
class="tool-link"
|
||||
@click.stop
|
||||
>{{ toolCall.result.similar_note.title }}</router-link>
|
||||
<span v-else class="tool-error">{{ toolCall.result.error }}</span>
|
||||
<template v-if="confirmState === 'created'">
|
||||
<span class="confirm-created">✓ Created</span>
|
||||
</template>
|
||||
<template v-else-if="confirmState === 'denied'">
|
||||
<span class="confirm-denied">Skipped</span>
|
||||
</template>
|
||||
<template v-else-if="confirmState !== 'creating'">
|
||||
<button class="btn-confirm-duplicate" @click.stop="confirmDuplicate">Create anyway</button>
|
||||
<button class="btn-deny-duplicate" @click.stop="denyDuplicate">Skip</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="tool-summary-count">Creating…</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="toolCall.status === 'error'">
|
||||
<span class="tool-error">{{ toolCall.result.error }}</span>
|
||||
</template>
|
||||
<template v-else-if="deletedNote">
|
||||
<span class="tool-deleted">{{ deletedNote.title || "Untitled" }}</span>
|
||||
</template>
|
||||
<template v-else-if="noteContent">
|
||||
<router-link :to="`/notes/${noteContent.id}`" class="tool-link" @click.stop>
|
||||
{{ noteContent.title || "Untitled" }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else-if="noteList !== null">
|
||||
<span class="tool-summary-count">{{ noteListCount }} found</span>
|
||||
</template>
|
||||
<template v-else-if="searchResults">
|
||||
<span class="tool-summary-count">
|
||||
{{ (toolCall.result.data?.count as number) ?? (toolCall.result.data?.total as number) ?? 0 }} found
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="eventData">
|
||||
<button v-if="eventData.id" class="tool-event-btn" @click.stop="openEventSlideOver(eventData.id)">
|
||||
<span class="tool-event-dot" v-if="eventData.color" :style="{ background: eventData.color }"></span>
|
||||
<span class="tool-event-title">{{ eventData.title }}</span>
|
||||
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<span class="tool-event-title">{{ eventData.title }}</span>
|
||||
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="updatedEvent">
|
||||
<button v-if="updatedEvent.id" class="tool-event-btn" @click.stop="openEventSlideOver(updatedEvent.id)">
|
||||
<span class="tool-event-dot" v-if="updatedEvent.color" :style="{ background: updatedEvent.color }"></span>
|
||||
<span class="tool-event-title">{{ updatedEvent.title }}</span>
|
||||
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<span class="tool-event-title">{{ updatedEvent.title }}</span>
|
||||
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="deletedEvent">
|
||||
<span class="tool-deleted">{{ deletedEvent.title }}</span>
|
||||
</template>
|
||||
<template v-else-if="calendarList !== null">
|
||||
<span class="tool-summary-count">{{ calendarList.length }} calendar{{ calendarList.length !== 1 ? "s" : "" }}</span>
|
||||
</template>
|
||||
<template v-else-if="todoData">
|
||||
<span :class="{ 'tool-deleted': toolCall.result.type === 'todo_deleted', 'tool-completed': toolCall.result.type === 'todo_completed' }">
|
||||
{{ todoData.summary }}
|
||||
</span>
|
||||
<span v-if="todoData.due" class="tool-event-time">{{ formatEventTime(todoData.due) }}</span>
|
||||
</template>
|
||||
<template v-else-if="todoList !== null">
|
||||
<span class="tool-summary-count">{{ todoCount }} found</span>
|
||||
</template>
|
||||
<template v-else-if="taskList !== null">
|
||||
<span class="tool-summary-count">{{ taskCount }} found</span>
|
||||
</template>
|
||||
<template v-else-if="eventList !== null">
|
||||
<span class="tool-summary-count">{{ eventCount }} found</span>
|
||||
</template>
|
||||
<template v-else-if="webSearch">
|
||||
<span class="tool-summary-count">
|
||||
{{ webSearch.count }} result{{ webSearch.count !== 1 ? "s" : "" }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="linkTo">
|
||||
<router-link :to="linkTo" class="tool-link" @click.stop>{{ title }}</router-link>
|
||||
</template>
|
||||
|
||||
<!-- Chevron toggle -->
|
||||
<span v-if="hasDetail" class="tool-chevron" :class="{ open: !collapsed }">▶</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Detail section (expandable) ─────────────────────────── -->
|
||||
<div v-if="hasDetail" v-show="!collapsed" class="tool-card-detail">
|
||||
<template v-if="noteContent && noteContent.tags.length">
|
||||
<span class="tool-note-tags">
|
||||
<span v-for="tag in noteContent.tags" :key="tag" class="tool-note-tag">#{{ tag }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="noteList !== null && noteList.length > 0">
|
||||
<div class="tool-search-results">
|
||||
<router-link
|
||||
v-for="n in noteList.slice(0, 8)"
|
||||
:key="n.id"
|
||||
:to="`/notes/${n.id}`"
|
||||
class="tool-search-item"
|
||||
>{{ n.title || "Untitled" }}</router-link>
|
||||
<span v-if="noteList.length > 8" class="tool-event-more">+{{ noteList.length - 8 }} more</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="searchResults">
|
||||
<div class="tool-search-results">
|
||||
<router-link
|
||||
v-for="r in searchResults"
|
||||
:key="r.id"
|
||||
:to="`/notes/${r.id}`"
|
||||
class="tool-search-item"
|
||||
>{{ r.title || "Untitled" }}</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="todoList !== null && todoList.length > 0">
|
||||
<div class="tool-event-list">
|
||||
<div v-for="(t, i) in todoList.slice(0, 5)" :key="i" class="tool-event-item">
|
||||
<span class="tool-event-item-title">{{ t.summary }}</span>
|
||||
<span v-if="t.due" class="tool-event-item-time">{{ formatEventTime(t.due) }}</span>
|
||||
</div>
|
||||
<div v-if="todoList.length > 5" class="tool-event-more">+{{ todoList.length - 5 }} more</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="taskList !== null && taskList.length > 0">
|
||||
<div class="tool-event-list">
|
||||
<div v-for="(t, i) in taskList.slice(0, 5)" :key="i" class="tool-event-item">
|
||||
<router-link :to="`/tasks/${t.id}`" class="tool-event-item-title">{{ t.title }}</router-link>
|
||||
<span v-if="t.due_date" class="tool-event-item-time">{{ t.due_date }}</span>
|
||||
<span v-if="t.priority && t.priority !== 'none'" class="tool-task-priority" :class="`priority-${t.priority}`">{{ t.priority }}</span>
|
||||
</div>
|
||||
<div v-if="taskList.length > 5" class="tool-event-more">+{{ taskList.length - 5 }} more</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="eventList !== null && eventList.length > 0">
|
||||
<div class="tool-event-cards">
|
||||
<button
|
||||
v-for="(ev, i) in eventList.slice(0, 5)"
|
||||
:key="i"
|
||||
class="tool-event-card"
|
||||
:class="{ clickable: !!ev.id }"
|
||||
@click="openEventSlideOver(ev.id)"
|
||||
>
|
||||
<span class="tool-event-card-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
||||
<span class="tool-event-card-body">
|
||||
<span class="tool-event-card-title">{{ ev.title }}</span>
|
||||
<span v-if="ev.start_dt" class="tool-event-card-time">{{ formatEventTime(ev.start_dt) }}</span>
|
||||
<span v-if="ev.location" class="tool-event-card-loc">{{ ev.location }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="eventList.length > 5" class="tool-event-more">+{{ eventList.length - 5 }} more</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="calendarList !== null">
|
||||
<div class="tool-calendar-list">
|
||||
<span v-for="name in calendarList" :key="name" class="tool-calendar-name">{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="webSearch">
|
||||
<div class="tool-search-results web-search-results">
|
||||
<a
|
||||
v-for="(r, i) in webSearch.results"
|
||||
:key="i"
|
||||
:href="r.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="tool-search-item"
|
||||
>{{ r.title || r.url }}</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Suggested tags always at the bottom of detail -->
|
||||
<div v-if="suggestedTags.length > 0" class="tag-suggestions">
|
||||
<span class="tag-suggestions-label">Suggested tags:</span>
|
||||
<button
|
||||
v-for="tag in suggestedTags"
|
||||
:key="tag"
|
||||
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
|
||||
:disabled="appliedTags.has(tag) || applyingTag === tag"
|
||||
@click="applyTag(tag)"
|
||||
>
|
||||
#{{ tag }}
|
||||
<span v-if="appliedTags.has(tag)" class="tag-check">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggested tags for non-detail cards (note/task created etc.) -->
|
||||
<div v-if="!hasDetail && suggestedTags.length > 0" class="tag-suggestions">
|
||||
<span class="tag-suggestions-label">Suggested tags:</span>
|
||||
<button
|
||||
v-for="tag in suggestedTags"
|
||||
:key="tag"
|
||||
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
|
||||
:disabled="appliedTags.has(tag) || applyingTag === tag"
|
||||
@click="applyTag(tag)"
|
||||
>
|
||||
#{{ tag }}
|
||||
<span v-if="appliedTags.has(tag)" class="tag-check">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event slide-over (portal-like, fixed positioned) -->
|
||||
<EventSlideOver
|
||||
v-if="eventSlideOverOpen"
|
||||
:event="eventSlideOverEntry"
|
||||
initial-date=""
|
||||
@close="closeEventSlideOver"
|
||||
@created="() => closeEventSlideOver(true)"
|
||||
@updated="() => closeEventSlideOver(true)"
|
||||
@deleted="() => closeEventSlideOver(true)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-call-card {
|
||||
display: inline-block;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.4rem;
|
||||
overflow: hidden;
|
||||
vertical-align: top;
|
||||
}
|
||||
.tool-call-card.error {
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
.tool-call-card.declined {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.tool-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
.tool-card-header.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.tool-card-header.clickable:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-chevron {
|
||||
font-size: 0.55rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: auto;
|
||||
padding-left: 0.4rem;
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tool-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tool-summary-count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ── Detail ── */
|
||||
.tool-card-detail {
|
||||
padding: 0.3rem 0.6rem 0.45rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Shared content styles ── */
|
||||
.tool-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tool-link:hover { text-decoration: underline; }
|
||||
|
||||
.tool-error { color: var(--color-danger, #e74c3c); }
|
||||
|
||||
.tool-declined-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tool-search-results {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tool-search-item {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.tool-search-item:hover { text-decoration: underline; }
|
||||
.tool-search-item:not(:last-child)::after {
|
||||
content: ",";
|
||||
color: var(--color-text-muted);
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
.web-search-results {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.web-search-results .tool-search-item::after { content: none; }
|
||||
|
||||
.tool-event-title { font-weight: 600; color: var(--color-text); }
|
||||
.tool-event-time { color: var(--color-text-muted); font-size: 0.75rem; }
|
||||
|
||||
.tool-event-list { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.tool-event-item { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; }
|
||||
.tool-event-item-title { color: var(--color-text); font-size: 0.8rem; }
|
||||
.tool-event-item-time { color: var(--color-text-muted); font-size: 0.75rem; white-space: nowrap; }
|
||||
.tool-event-more { color: var(--color-text-muted); font-size: 0.75rem; font-style: italic; }
|
||||
|
||||
.tool-deleted { text-decoration: line-through; opacity: 0.7; }
|
||||
.tool-completed { text-decoration: line-through; color: var(--color-success, #2ecc71); }
|
||||
|
||||
.tool-note-tags { display: flex; flex-wrap: wrap; gap: 0.2rem; }
|
||||
.tool-note-tag { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
|
||||
.tool-task-priority {
|
||||
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; padding: 0.1rem 0.3rem; border-radius: 3px;
|
||||
}
|
||||
.priority-high { color: var(--color-danger, #e74c3c); }
|
||||
.priority-medium { color: var(--color-warning, #f59e0b); }
|
||||
.priority-low { color: var(--color-text-muted); }
|
||||
|
||||
.tool-calendar-list { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.tool-calendar-name {
|
||||
display: inline-block; padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px; background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border); font-size: 0.75rem; color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Tag suggestions ── */
|
||||
.tag-suggestions {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem; border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.tool-call-card:not(:has(.tool-card-detail)) .tag-suggestions {
|
||||
/* When outside detail, no top border needed if it's the only extra content */
|
||||
}
|
||||
.tag-suggestions-label { font-size: 0.7rem; color: var(--color-text-muted); font-weight: 500; }
|
||||
|
||||
.tag-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.2rem;
|
||||
padding: 0.15rem 0.5rem; border: 1px solid var(--color-primary);
|
||||
border-radius: 999px; background: transparent; color: var(--color-primary);
|
||||
font-size: 0.75rem; cursor: pointer; transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.tag-pill:hover:not(:disabled) { background: var(--color-primary); color: #fff; }
|
||||
.tag-pill.applied {
|
||||
background: var(--color-success, #2ecc71); border-color: var(--color-success, #2ecc71);
|
||||
color: #fff; cursor: default;
|
||||
}
|
||||
.tag-pill:disabled:not(.applied) { opacity: 0.6; cursor: wait; }
|
||||
.tag-check { font-size: 0.65rem; }
|
||||
|
||||
/* Duplicate confirmation */
|
||||
.tool-call-card.requires-confirm {
|
||||
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 60%, transparent);
|
||||
}
|
||||
.confirm-created {
|
||||
color: var(--color-success, #2ecc71);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-denied {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.btn-confirm-duplicate {
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-confirm-duplicate:hover { opacity: 0.9; }
|
||||
.btn-deny-duplicate {
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-deny-duplicate:hover {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
|
||||
/* ── Event header click button ── */
|
||||
.tool-event-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tool-event-btn:hover .tool-event-title {
|
||||
text-decoration: underline;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tool-event-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Event cards (list) ── */
|
||||
.tool-event-cards { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.tool-event-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card, #16161a);
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
cursor: default;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.tool-event-card.clickable { cursor: pointer; }
|
||||
.tool-event-card.clickable:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card, #16161a));
|
||||
}
|
||||
.tool-event-card-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.tool-event-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tool-event-card-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tool-event-card-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.tool-event-card-loc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -1,108 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { ToolPendingRecord } from "@/types/chat";
|
||||
|
||||
const props = defineProps<{
|
||||
pendingTool: ToolPendingRecord;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "accept"): void;
|
||||
(e: "decline"): void;
|
||||
}>();
|
||||
|
||||
const label = computed(() => props.pendingTool.label ?? props.pendingTool.function);
|
||||
|
||||
const detail = computed(() => {
|
||||
const args = props.pendingTool.arguments;
|
||||
const name =
|
||||
(args.title as string | undefined) ??
|
||||
(args.summary as string | undefined) ??
|
||||
(args.query as string | undefined) ??
|
||||
"";
|
||||
return name ? `"${name}"` : "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-confirm-card">
|
||||
<div class="tool-confirm-info">
|
||||
<span class="tool-confirm-label">{{ label }}</span>
|
||||
<span v-if="detail" class="tool-confirm-detail">{{ detail }}</span>
|
||||
</div>
|
||||
<div class="tool-confirm-actions">
|
||||
<button class="btn-accept" @click="emit('accept')">Accept</button>
|
||||
<button class="btn-decline" @click="emit('decline')">Decline</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-confirm-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-warning, #f59e0b);
|
||||
border-radius: 12px;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.4rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tool-confirm-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.tool-confirm-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool-confirm-detail {
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool-confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-accept,
|
||||
.btn-decline {
|
||||
padding: 0.25rem 0.65rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-accept {
|
||||
background: var(--color-success, #2ecc71);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-decline {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-accept:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.btn-decline:hover {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
</style>
|
||||
@@ -202,7 +202,6 @@ function restore() {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.vh-item {
|
||||
|
||||
@@ -9,6 +9,8 @@ interface ForecastDay {
|
||||
precip_probability: number | null
|
||||
precip_mm: number | null
|
||||
windspeed_max: number
|
||||
precip_summary?: string
|
||||
precip_peak_hour?: string
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
@@ -21,6 +23,7 @@ interface WeatherData {
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
precip_summary?: string | null
|
||||
forecast: ForecastDay[]
|
||||
}
|
||||
|
||||
@@ -68,6 +71,11 @@ const fetchedAtLabel = computed(() => {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function hasPrecip(day: ForecastDay): boolean {
|
||||
return (day.precip_probability != null && day.precip_probability > 0) ||
|
||||
(day.precip_mm != null && day.precip_mm > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,6 +93,9 @@ const fetchedAtLabel = computed(() => {
|
||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
||||
</div>
|
||||
<div v-if="weather.precip_summary" class="weather-precip-summary">
|
||||
💧 {{ weather.precip_summary }}
|
||||
</div>
|
||||
<table class="weather-forecast" v-if="weather.forecast.length">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -100,8 +111,14 @@ const fetchedAtLabel = computed(() => {
|
||||
<td class="forecast-day-name">{{ day.day }}</td>
|
||||
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
|
||||
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
|
||||
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !(day.precip_probability != null && day.precip_probability > 0) && !(day.precip_mm != null && day.precip_mm > 0) }">
|
||||
<template v-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
|
||||
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
|
||||
<template v-if="day.precip_summary">
|
||||
<span class="precip-detail" :title="day.precip_summary">
|
||||
{{ day.precip_probability }}%
|
||||
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
|
||||
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
@@ -168,7 +185,7 @@ const fetchedAtLabel = computed(() => {
|
||||
|
||||
.weather-today {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -177,6 +194,16 @@ const fetchedAtLabel = computed(() => {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.weather-precip-summary {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.83rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.weather-forecast {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -227,6 +254,18 @@ const fetchedAtLabel = computed(() => {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.precip-detail {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.precip-peak {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.forecast-wind {
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
@@ -234,6 +273,5 @@ const fetchedAtLabel = computed(() => {
|
||||
|
||||
.weather-unavailable {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import WordCount from "@/components/WordCount.vue";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
@@ -316,7 +317,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
{{ creatingNote ? '…' : '+' }}
|
||||
</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote">✕</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rail-title">Notes</span>
|
||||
@@ -332,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
type="search"
|
||||
aria-label="Search notes"
|
||||
/>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''">✕</button>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div v-if="listLoading" class="rail-state">Loading…</div>
|
||||
@@ -360,10 +361,10 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)">✕</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -416,7 +417,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
||||
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()">✕</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-row">
|
||||
@@ -429,7 +430,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||
</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []">✕</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||
@@ -457,12 +458,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
/* ── Left rail ── */
|
||||
.note-rail {
|
||||
width: 155px;
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
@@ -479,7 +480,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -532,7 +533,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
padding: 1rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Note list */
|
||||
@@ -551,13 +551,13 @@ defineExpose({ reload: loadProjectNotes });
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
gap: 0.15rem;
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.note-row.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
border-left-color: var(--color-primary);
|
||||
border-right-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.note-row-main {
|
||||
@@ -630,18 +630,20 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
}
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
.btn-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-delete:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-confirm-delete {
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-delete {
|
||||
@@ -707,7 +709,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Editor UI */
|
||||
@@ -715,7 +716,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -730,20 +730,22 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
||||
|
||||
/* Moss action-primary per Hybrid */
|
||||
.btn-save {
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.note-title-row {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0.9rem 1.1rem 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -751,19 +753,24 @@ defineExpose({ reload: loadProjectNotes });
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text);
|
||||
padding: 0;
|
||||
font-family: 'Fraunces', serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.note-title-input:focus { outline: none; }
|
||||
.note-title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
@@ -789,7 +796,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -824,7 +830,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.toolbar-row {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -834,7 +839,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -842,7 +846,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
@@ -298,12 +299,12 @@ defineExpose({ reload: loadAll });
|
||||
</span>
|
||||
<template v-if="deleteConfirmPending">
|
||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask">✕</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail">✕</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
||||
@@ -373,7 +374,7 @@ defineExpose({ reload: loadAll });
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-add {
|
||||
@@ -432,7 +433,7 @@ defineExpose({ reload: loadAll });
|
||||
.ms-group-header:hover { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
|
||||
|
||||
.ms-chevron { font-size: 0.6rem; color: var(--color-text-muted); width: 0.8rem; }
|
||||
.ms-name { flex: 1; font-weight: 600; font-size: 0.8rem; }
|
||||
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
|
||||
.ms-count { font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); border-radius: 10px; padding: 0 0.4rem; }
|
||||
|
||||
.ms-status {
|
||||
@@ -494,7 +495,7 @@ defineExpose({ reload: loadAll });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); font-style: italic; }
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); }
|
||||
|
||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
|
||||
|
||||
@@ -522,7 +523,7 @@ defineExpose({ reload: loadAll });
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: none;
|
||||
@@ -575,19 +576,21 @@ defineExpose({ reload: loadAll });
|
||||
border-radius: 3px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.btn-delete-task:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-delete-task:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-delete-confirm {
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.25rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-delete-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-delete-cancel {
|
||||
@@ -603,7 +606,7 @@ defineExpose({ reload: loadAll });
|
||||
.detail-title {
|
||||
padding: 0.75rem 0.75rem 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
@@ -672,7 +675,7 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
.task-due.overdue {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Close detail button */
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { getProjectApplicableRules } from "@/api/rulebooks";
|
||||
import type { ApplicableRules } from "@/api/rulebooks";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
const data = ref<ApplicableRules | null>(null);
|
||||
|
||||
function grouped(rules: ApplicableRules["rules"]) {
|
||||
const g: Record<string, Record<string, ApplicableRules["rules"]>> = {};
|
||||
for (const r of rules) {
|
||||
(g[r.rulebook_title] ??= {});
|
||||
(g[r.rulebook_title][r.topic_title] ??= []).push(r);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
data.value = await getProjectApplicableRules(props.projectId);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.projectId, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="plan-rules" v-if="data && data.rules.length">
|
||||
<h3>Applicable rules</h3>
|
||||
<div v-for="(topics, rb) in grouped(data.rules)" :key="rb" class="rb">
|
||||
<h4>{{ rb }}</h4>
|
||||
<div v-for="(rules, topic) in topics" :key="topic">
|
||||
<h5>{{ topic }}</h5>
|
||||
<ul>
|
||||
<li v-for="r in rules" :key="r.id">
|
||||
<strong>{{ r.title }}</strong> — {{ r.statement }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="data.truncated" class="truncated">
|
||||
Truncated at 50 rules — more apply.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plan-rules {
|
||||
margin-top: 1.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.plan-rules h3 {
|
||||
font-size: 0.9em; opacity: 0.7;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
||||
.rb h5 {
|
||||
font-size: 0.8em; opacity: 0.7;
|
||||
text-transform: uppercase; margin-top: 0.5rem;
|
||||
}
|
||||
.plan-rules ul {
|
||||
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
}
|
||||
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
|
||||
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
||||
listRulebooks, getRule,
|
||||
} from "@/api/rulebooks";
|
||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
const router = useRouter();
|
||||
const applicable = ref<ApplicableRules | null>(null);
|
||||
const allRulebooks = ref<Rulebook[]>([]);
|
||||
const showPicker = ref(false);
|
||||
const expandedRuleIds = ref<Set<number>>(new Set());
|
||||
|
||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||
|
||||
async function load() {
|
||||
applicable.value = await getProjectApplicableRules(props.projectId);
|
||||
}
|
||||
|
||||
async function loadAllRulebooks() {
|
||||
allRulebooks.value = await listRulebooks();
|
||||
}
|
||||
|
||||
async function subscribe(rulebookId: number) {
|
||||
await subscribeProject(props.projectId, rulebookId);
|
||||
showPicker.value = false;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function unsubscribe(rulebookId: number) {
|
||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||
await unsubscribeProject(props.projectId, rulebookId);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function toggleRuleExpand(ruleId: number) {
|
||||
if (expandedRuleIds.value.has(ruleId)) {
|
||||
expandedRuleIds.value.delete(ruleId);
|
||||
} else {
|
||||
expandedRuleIds.value.add(ruleId);
|
||||
if (!ruleDetails.value[ruleId]) {
|
||||
const rule = await getRule(ruleId);
|
||||
ruleDetails.value[ruleId] = {
|
||||
why: rule.why || "",
|
||||
how_to_apply: rule.how_to_apply || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
// trigger reactivity on Set mutation
|
||||
expandedRuleIds.value = new Set(expandedRuleIds.value);
|
||||
}
|
||||
|
||||
function openInRulesView(rulebookId: number, ruleId?: number) {
|
||||
const query: Record<string, string> = { rb: String(rulebookId) };
|
||||
if (ruleId) query.rule = String(ruleId);
|
||||
router.push({ path: "/rules", query });
|
||||
}
|
||||
|
||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]) {
|
||||
const grouped: Record<string, Record<string, ApplicableRules["rules"]>> = {};
|
||||
for (const r of rules) {
|
||||
if (!grouped[r.rulebook_title]) grouped[r.rulebook_title] = {};
|
||||
if (!grouped[r.rulebook_title][r.topic_title]) grouped[r.rulebook_title][r.topic_title] = [];
|
||||
grouped[r.rulebook_title][r.topic_title].push(r);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function rulebookIdForTitle(title: string): number | undefined {
|
||||
return applicable.value?.subscribed_rulebooks.find((rb) => rb.title === title)?.id;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
await loadAllRulebooks();
|
||||
});
|
||||
|
||||
watch(() => props.projectId, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rules-tab" v-if="applicable">
|
||||
<section class="subscribed">
|
||||
<h3>Subscribed rulebooks</h3>
|
||||
<div class="chips">
|
||||
<span
|
||||
v-for="rb in applicable.subscribed_rulebooks"
|
||||
:key="rb.id"
|
||||
class="chip"
|
||||
>
|
||||
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
|
||||
<button class="chip-remove" @click="unsubscribe(rb.id)" aria-label="Unsubscribe">×</button>
|
||||
</span>
|
||||
<button v-if="!showPicker" class="add" @click="showPicker = true">+ Subscribe</button>
|
||||
<select
|
||||
v-else
|
||||
@change="subscribe(Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option value="">Choose a rulebook…</option>
|
||||
<option
|
||||
v-for="rb in allRulebooks.filter((rb) => !applicable!.subscribed_rulebooks.some((s) => s.id === rb.id))"
|
||||
:key="rb.id"
|
||||
:value="rb.id"
|
||||
>
|
||||
{{ rb.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="applicable">
|
||||
<h3>Applicable rules</h3>
|
||||
<p v-if="applicable.rules.length === 0" class="empty">
|
||||
No rules yet — subscribe to a rulebook above, or create one at
|
||||
<a @click="router.push('/rules')">Rulebooks</a>.
|
||||
</p>
|
||||
<div
|
||||
v-for="(topics, rbTitle) in groupByRulebookAndTopic(applicable.rules)"
|
||||
:key="rbTitle"
|
||||
class="rb-group"
|
||||
>
|
||||
<h4>{{ rbTitle }}</h4>
|
||||
<div v-for="(rules, topicTitle) in topics" :key="topicTitle" class="topic-group">
|
||||
<h5>{{ topicTitle }}</h5>
|
||||
<ul>
|
||||
<li v-for="r in rules" :key="r.id" class="rule">
|
||||
<div class="rule-head" @click="toggleRuleExpand(r.id)">
|
||||
<span class="rule-title">{{ r.title }}</span>
|
||||
<span class="rule-statement">{{ r.statement }}</span>
|
||||
</div>
|
||||
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
||||
<div v-if="ruleDetails[r.id].why">
|
||||
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<button
|
||||
class="edit-link"
|
||||
@click="rulebookIdForTitle(String(rbTitle)) && openInRulesView(rulebookIdForTitle(String(rbTitle))!, r.id)"
|
||||
>
|
||||
Edit in Rulebook →
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="applicable.truncated" class="truncated">
|
||||
Truncated at 50 rules — there are more applicable rules.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rules-tab { padding: 1rem; }
|
||||
h3 {
|
||||
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-top: 0;
|
||||
}
|
||||
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
background: var(--color-primary-bg, rgba(99,102,241,0.15));
|
||||
padding: 0.25rem 0.5rem; border-radius: 999px;
|
||||
}
|
||||
.chip a { cursor: pointer; }
|
||||
.chip-remove { background: none; border: none; cursor: pointer; opacity: 0.5; font-size: 1.1em; }
|
||||
.chip-remove:hover { opacity: 1; }
|
||||
.add {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border, #2a2a2e);
|
||||
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
select {
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.applicable { margin-top: 2rem; }
|
||||
.rb-group { margin-bottom: 1.5rem; }
|
||||
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
||||
.topic-group h5 {
|
||||
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rule {
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
padding-left: 0.75rem; margin: 0.5rem 0;
|
||||
}
|
||||
.rule-head { cursor: pointer; }
|
||||
.rule-title { font-weight: 500; }
|
||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||
.rule-detail {
|
||||
margin-top: 0.5rem; padding: 0.5rem;
|
||||
background: var(--color-bg, #111113); border-radius: 6px;
|
||||
}
|
||||
.rule-detail > div { margin-bottom: 0.5rem; }
|
||||
.edit-link {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
|
||||
}
|
||||
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
||||
.empty a { cursor: pointer; text-decoration: underline; }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
|
||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const title = ref("");
|
||||
const statement = ref("");
|
||||
const why = ref("");
|
||||
const howToApply = ref("");
|
||||
|
||||
const isCreating = ref(props.ruleId === null);
|
||||
|
||||
async function load() {
|
||||
if (props.ruleId !== null) {
|
||||
await store.fetchRule(props.ruleId);
|
||||
const r = store.currentRule;
|
||||
if (r) {
|
||||
title.value = r.title;
|
||||
statement.value = r.statement;
|
||||
why.value = r.why || "";
|
||||
howToApply.value = r.how_to_apply || "";
|
||||
}
|
||||
} else {
|
||||
title.value = "";
|
||||
statement.value = "";
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!title.value.trim() || !statement.value.trim()) {
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
if (isCreating.value && props.topicId !== null) {
|
||||
await store.createRule(props.topicId, {
|
||||
title: title.value, statement: statement.value,
|
||||
why: why.value, how_to_apply: howToApply.value,
|
||||
});
|
||||
} else if (props.ruleId !== null) {
|
||||
await store.updateRule(props.ruleId, {
|
||||
title: title.value, statement: statement.value,
|
||||
why: why.value, how_to_apply: howToApply.value,
|
||||
});
|
||||
}
|
||||
emit("close");
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (props.ruleId === null) return;
|
||||
if (!confirm("Delete this rule? This cannot be undone.")) return;
|
||||
await store.deleteRule(props.ruleId);
|
||||
emit("close");
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.ruleId, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backdrop" @click="save">
|
||||
<aside class="slide-over" @click.stop>
|
||||
<header>
|
||||
<h2>{{ isCreating ? "New rule" : "Edit rule" }}</h2>
|
||||
<button v-if="!isCreating" class="trash" @click="remove" aria-label="Delete">🗑</button>
|
||||
<button class="close" @click="save" aria-label="Close">×</button>
|
||||
</header>
|
||||
<label>
|
||||
Title
|
||||
<input v-model="title" placeholder="e.g. dev is home" />
|
||||
</label>
|
||||
<label>
|
||||
Statement <span class="required">*</span>
|
||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||
</label>
|
||||
<label>
|
||||
Why
|
||||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||||
</label>
|
||||
<label>
|
||||
How to apply
|
||||
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
||||
</label>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
}
|
||||
.slide-over {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: min(520px, 90vw);
|
||||
background: var(--color-surface, #18181b);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
header { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
header h2 {
|
||||
flex: 1; margin: 0;
|
||||
font-family: Fraunces, serif; font-style: italic;
|
||||
}
|
||||
label { display: block; margin-bottom: 1rem; }
|
||||
.required { color: var(--color-primary, #6366f1); }
|
||||
input, textarea {
|
||||
width: 100%; margin-top: 0.25rem;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
padding: 0.5rem; font: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||
.trash:hover, .close:hover { opacity: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { RuleHeader } from "@/api/rulebooks";
|
||||
|
||||
defineProps<{ topicId: number; rules: RuleHeader[] }>();
|
||||
const emit = defineEmits<{
|
||||
"open-rule": [id: number];
|
||||
"create-rule": [topicId: number];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pane">
|
||||
<header><h2>Rules</h2></header>
|
||||
<ul>
|
||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||
<div class="title">{{ r.title }}</div>
|
||||
<div class="statement">{{ r.statement }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
margin-bottom: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||
.new-rule { cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { apiGet } from "@/api/client";
|
||||
import {
|
||||
subscribeProject, unsubscribeProject, getProjectApplicableRules,
|
||||
} from "@/api/rulebooks";
|
||||
import type { RulebookTopic } from "@/api/rulebooks";
|
||||
|
||||
const props = defineProps<{
|
||||
rulebookId: number;
|
||||
topics: RulebookTopic[];
|
||||
selectedTopicId: number | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ "select-topic": [id: number] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const isCreating = ref(false);
|
||||
const newTitle = ref("");
|
||||
|
||||
interface ProjectLite { id: number; title: string }
|
||||
const projects = ref<ProjectLite[]>([]);
|
||||
// Map<project_id, Set<rulebook_id>>
|
||||
const subscribedRulebookIds = ref<Map<number, Set<number>>>(new Map());
|
||||
|
||||
async function loadProjects() {
|
||||
const data = await apiGet<{ projects: ProjectLite[] }>("/api/projects");
|
||||
projects.value = data.projects;
|
||||
for (const p of projects.value) {
|
||||
const result = await getProjectApplicableRules(p.id);
|
||||
subscribedRulebookIds.value.set(
|
||||
p.id,
|
||||
new Set(result.subscribed_rulebooks.map((rb) => rb.id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isSubscribed(projectId: number): boolean {
|
||||
return subscribedRulebookIds.value.get(projectId)?.has(props.rulebookId) ?? false;
|
||||
}
|
||||
|
||||
async function toggleSubscription(projectId: number, checked: boolean) {
|
||||
if (checked) {
|
||||
await subscribeProject(projectId, props.rulebookId);
|
||||
const set = subscribedRulebookIds.value.get(projectId) || new Set<number>();
|
||||
set.add(props.rulebookId);
|
||||
subscribedRulebookIds.value.set(projectId, set);
|
||||
} else {
|
||||
await unsubscribeProject(projectId, props.rulebookId);
|
||||
subscribedRulebookIds.value.get(projectId)?.delete(props.rulebookId);
|
||||
}
|
||||
// trigger reactivity on Map mutation
|
||||
subscribedRulebookIds.value = new Map(subscribedRulebookIds.value);
|
||||
}
|
||||
|
||||
async function submitNew() {
|
||||
const title = newTitle.value.trim();
|
||||
if (!title) return;
|
||||
const topic = await store.createTopic(props.rulebookId, { title });
|
||||
newTitle.value = "";
|
||||
isCreating.value = false;
|
||||
emit("select-topic", topic.id);
|
||||
}
|
||||
|
||||
onMounted(loadProjects);
|
||||
watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing map */});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pane">
|
||||
<header><h2>Topics</h2></header>
|
||||
<ul>
|
||||
<li
|
||||
v-for="t in topics"
|
||||
:key="t.id"
|
||||
:class="{ active: t.id === selectedTopicId }"
|
||||
@click="emit('select-topic', t.id)"
|
||||
>
|
||||
{{ t.title }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="new-topic">
|
||||
<button v-if="!isCreating" @click="isCreating = true">+ New topic</button>
|
||||
<form v-else @submit.prevent="submitNew">
|
||||
<input v-model="newTitle" autofocus placeholder="Topic title (e.g. git-workflow)" />
|
||||
<div class="form-buttons">
|
||||
<button type="submit">Create</button>
|
||||
<button type="button" @click="isCreating = false">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="subscriptions">
|
||||
<h3>Subscribers</h3>
|
||||
<ul class="sub-list">
|
||||
<li v-for="p in projects" :key="p.id">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSubscribed(p.id)"
|
||||
@change="toggleSubscription(p.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ p.title }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
.new-topic input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
.subscriptions {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.sub-list li { cursor: default; }
|
||||
.sub-list label { display: flex; gap: 0.5rem; align-items: center; cursor: pointer; }
|
||||
button { cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { Rulebook } from "@/api/rulebooks";
|
||||
|
||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
|
||||
const emit = defineEmits<{ select: [id: number] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const isCreating = ref(false);
|
||||
const newTitle = ref("");
|
||||
|
||||
async function submitNew() {
|
||||
const title = newTitle.value.trim();
|
||||
if (!title) return;
|
||||
const rb = await store.createRulebook({ title });
|
||||
newTitle.value = "";
|
||||
isCreating.value = false;
|
||||
emit("select", rb.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="pane">
|
||||
<header><h2>Rulebooks</h2></header>
|
||||
<ul>
|
||||
<li
|
||||
v-for="rb in rulebooks"
|
||||
:key="rb.id"
|
||||
:class="{ active: rb.id === selectedId }"
|
||||
@click="emit('select', rb.id)"
|
||||
>
|
||||
<span class="title">{{ rb.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="new-rulebook">
|
||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||
<form v-else @submit.prevent="submitNew">
|
||||
<input v-model="newTitle" autofocus placeholder="Rulebook title" />
|
||||
<div class="form-buttons">
|
||||
<button type="submit">Create</button>
|
||||
<button type="button" @click="isCreating = false">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
.new-rulebook { margin-top: 1rem; }
|
||||
.new-rulebook input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
button { cursor: pointer; }
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { synthesiseSpeech } from '@/api/client'
|
||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||||
|
||||
/** Minimum stripped character count to bother synthesizing. */
|
||||
const MIN_CHARS = 3
|
||||
|
||||
/** Matches sentence-terminal punctuation followed by whitespace or end-of-string. */
|
||||
const SENTENCE_BOUNDARY = /[.!?]+(?=\s|$)/
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
|
||||
.replace(/#{1,6}\s+/g, '')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/\n{2,}/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract completed sentences from `text` using SENTENCE_BOUNDARY.
|
||||
* Returns the sentences found and the unconsumed remainder.
|
||||
*/
|
||||
function extractSentences(text: string): { sentences: string[]; remainder: string } {
|
||||
const sentences: string[] = []
|
||||
let remaining = text
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = SENTENCE_BOUNDARY.exec(remaining)) !== null) {
|
||||
const boundary = match.index + match[0].length
|
||||
const sentence = remaining.slice(0, boundary).trim()
|
||||
if (sentence) sentences.push(sentence)
|
||||
remaining = remaining.slice(boundary)
|
||||
}
|
||||
|
||||
return { sentences, remainder: remaining }
|
||||
}
|
||||
|
||||
export interface UseStreamingTtsOptions {
|
||||
streamingContent: Ref<string> | ComputedRef<string>
|
||||
streaming: Ref<boolean> | ComputedRef<boolean>
|
||||
enabled: Ref<boolean> | ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export interface UseStreamingTtsReturn {
|
||||
/** True while any synthesis request is in-flight or audio is playing. */
|
||||
speaking: ComputedRef<boolean>
|
||||
/** Cancel all in-flight synthesis/playback and clear the queue. */
|
||||
stop: () => void
|
||||
/** Speak a complete text through the same sentence-chunking pipeline. */
|
||||
speak: (text: string) => void
|
||||
}
|
||||
|
||||
export function useStreamingTts(options: UseStreamingTtsOptions): UseStreamingTtsReturn {
|
||||
const { streamingContent, streaming, enabled } = options
|
||||
const audio = useVoiceAudio()
|
||||
|
||||
let sentenceBuffer = ''
|
||||
let lastSeenLength = 0
|
||||
let abortId = 0
|
||||
let playQueue: Promise<void> = Promise.resolve()
|
||||
const pendingCount = ref(0)
|
||||
|
||||
const speaking = computed(() => pendingCount.value > 0 || audio.playing.value)
|
||||
|
||||
function stop(): void {
|
||||
abortId++
|
||||
sentenceBuffer = ''
|
||||
lastSeenLength = 0
|
||||
playQueue = Promise.resolve()
|
||||
audio.stop()
|
||||
pendingCount.value = 0
|
||||
}
|
||||
|
||||
async function enqueueSentence(sentence: string, myAbortId: number): Promise<void> {
|
||||
const stripped = stripMarkdown(sentence)
|
||||
if (stripped.length < MIN_CHARS) return
|
||||
|
||||
pendingCount.value++
|
||||
let blob: Blob | null = null
|
||||
try {
|
||||
blob = await synthesiseSpeech(stripped)
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
console.warn('[StreamingTTS] Synthesis failed, retrying', {
|
||||
chars: stripped.length,
|
||||
preview: stripped.slice(0, 80),
|
||||
error: errMsg,
|
||||
})
|
||||
try {
|
||||
blob = await synthesiseSpeech(stripped)
|
||||
} catch (e2) {
|
||||
const errMsg2 = e2 instanceof Error ? e2.message : String(e2)
|
||||
console.warn('[StreamingTTS] Retry failed, sentence dropped', {
|
||||
chars: stripped.length,
|
||||
preview: stripped.slice(0, 80),
|
||||
error: errMsg2,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
pendingCount.value--
|
||||
}
|
||||
|
||||
if (!blob) return
|
||||
|
||||
// Capture blob for the closure — TS can't narrow after async gap
|
||||
const resolvedBlob = blob
|
||||
playQueue = playQueue.then(async () => {
|
||||
if (abortId !== myAbortId) return
|
||||
await audio.play(resolvedBlob)
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchBuffer(flush: boolean): void {
|
||||
if (!enabled.value) return
|
||||
const myAbortId = abortId
|
||||
const { sentences, remainder } = extractSentences(sentenceBuffer)
|
||||
sentenceBuffer = flush ? '' : remainder
|
||||
for (const sentence of sentences) {
|
||||
enqueueSentence(sentence, myAbortId)
|
||||
}
|
||||
if (flush && remainder.trim().length >= MIN_CHARS) {
|
||||
enqueueSentence(remainder.trim(), myAbortId)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch accumulating content — extract new characters since last check
|
||||
watch(streamingContent, (newContent) => {
|
||||
if (!enabled.value) return
|
||||
const delta = newContent.slice(lastSeenLength)
|
||||
lastSeenLength = newContent.length
|
||||
sentenceBuffer += delta
|
||||
dispatchBuffer(false)
|
||||
})
|
||||
|
||||
// Watch streaming flag — stop on new message start, flush on end
|
||||
watch(streaming, (isStreaming) => {
|
||||
if (!enabled.value) return
|
||||
if (isStreaming) {
|
||||
// New message starting — cancel previous response's audio
|
||||
stop()
|
||||
} else {
|
||||
// Stream ended — flush any remaining fragment
|
||||
dispatchBuffer(true)
|
||||
lastSeenLength = 0
|
||||
}
|
||||
})
|
||||
|
||||
function speak(text: string): void {
|
||||
stop()
|
||||
if (!text.trim()) return
|
||||
const myAbortId = abortId
|
||||
const { sentences, remainder } = extractSentences(text)
|
||||
for (const sentence of sentences) {
|
||||
enqueueSentence(sentence, myAbortId)
|
||||
}
|
||||
if (remainder.trim().length >= MIN_CHARS) {
|
||||
enqueueSentence(remainder.trim(), myAbortId)
|
||||
}
|
||||
}
|
||||
|
||||
return { speaking, stop, speak }
|
||||
}
|
||||
@@ -2,13 +2,10 @@ import { ref, readonly } from 'vue'
|
||||
import type { MicVAD } from '@ricky0123/vad-web'
|
||||
|
||||
export interface UseVadOptions {
|
||||
/** Called when VAD detects a real speech-end event after the grace period. */
|
||||
onSpeechEnd: () => void
|
||||
/** Called when stopAndCheck() is invoked but no speech was ever detected. */
|
||||
onNoSpeech: () => void
|
||||
/** Minimum ms between speech-start and speech-end before the end event fires. */
|
||||
onVadError?: (message: string) => void
|
||||
graceMs?: number
|
||||
/** Minimum total recording duration before speech-end can fire. */
|
||||
minRecordingMs?: number
|
||||
}
|
||||
|
||||
@@ -108,8 +105,8 @@ export function useVad(options: UseVadOptions) {
|
||||
await micVad.start()
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
// VAD init failed — the user can still manual-stop. Log for debugging.
|
||||
console.error('VAD init failed:', e)
|
||||
options.onVadError?.(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
const VOLUME_KEY = 'fa_voice_volume'
|
||||
|
||||
// Shared volume across all instances — persisted to localStorage per device
|
||||
const _volume = ref<number>(parseFloat(localStorage.getItem(VOLUME_KEY) ?? '1.0'))
|
||||
|
||||
export function useVoiceAudio() {
|
||||
const playing = ref(false)
|
||||
const isSupported = typeof AudioContext !== 'undefined' || typeof (window as unknown as Record<string, unknown>).webkitAudioContext !== 'undefined'
|
||||
|
||||
let audioCtx: AudioContext | null = null
|
||||
let currentSource: AudioBufferSourceNode | null = null
|
||||
|
||||
function _getCtx(): AudioContext {
|
||||
if (!audioCtx || audioCtx.state === 'closed') {
|
||||
const Ctx = window.AudioContext ?? (window as unknown as Record<string, typeof AudioContext>).webkitAudioContext
|
||||
audioCtx = new Ctx()
|
||||
}
|
||||
return audioCtx
|
||||
}
|
||||
|
||||
async function play(blob: Blob): Promise<void> {
|
||||
stop()
|
||||
const ctx = _getCtx()
|
||||
if (ctx.state === 'suspended') await ctx.resume()
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer()
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
|
||||
const source = ctx.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.value = _volume.value
|
||||
source.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
|
||||
currentSource = source
|
||||
playing.value = true
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
source.onended = () => {
|
||||
playing.value = false
|
||||
currentSource = null
|
||||
resolve()
|
||||
}
|
||||
source.start(0)
|
||||
})
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (currentSource) {
|
||||
try { currentSource.stop() } catch { /* already stopped */ }
|
||||
currentSource = null
|
||||
}
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
playing: readonly(playing),
|
||||
volume: _volume,
|
||||
isSupported,
|
||||
play,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
||||
export function setVoiceVolume(v: number) {
|
||||
_volume.value = Math.max(0, Math.min(1, v))
|
||||
localStorage.setItem(VOLUME_KEY, String(_volume.value))
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { ref, readonly, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Push-to-talk recorder wrapping the browser MediaRecorder API.
|
||||
*
|
||||
* Usage:
|
||||
* const { recording, error, isSupported, startRecording, stopRecording } = useVoiceRecorder()
|
||||
* await startRecording()
|
||||
* const blob = await stopRecording() // resolves with the recorded audio Blob
|
||||
*/
|
||||
export function useVoiceRecorder() {
|
||||
const recording = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isSupported = typeof MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let chunks: Blob[] = []
|
||||
const streamRef = ref<MediaStream | null>(null)
|
||||
let resolveStop: ((blob: Blob) => void) | null = null
|
||||
let rejectStop: ((err: Error) => void) | null = null
|
||||
|
||||
async function startRecording(): Promise<void> {
|
||||
error.value = null
|
||||
if (!isSupported) {
|
||||
error.value = 'Audio recording is not supported in this browser'
|
||||
return
|
||||
}
|
||||
if (recording.value) return
|
||||
|
||||
try {
|
||||
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (e) {
|
||||
error.value = 'Microphone access denied'
|
||||
return
|
||||
}
|
||||
|
||||
chunks = []
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: MediaRecorder.isTypeSupported('audio/webm')
|
||||
? 'audio/webm'
|
||||
: ''
|
||||
|
||||
mediaRecorder = mimeType ? new MediaRecorder(streamRef.value!, { mimeType }) : new MediaRecorder(streamRef.value!)
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunks.push(e.data)
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType ?? 'audio/webm' })
|
||||
chunks = []
|
||||
streamRef.value?.getTracks().forEach((t) => t.stop())
|
||||
streamRef.value = null
|
||||
recording.value = false
|
||||
resolveStop?.(blob)
|
||||
resolveStop = null
|
||||
rejectStop = null
|
||||
}
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
recording.value = false
|
||||
error.value = 'Recording error'
|
||||
rejectStop?.(new Error('MediaRecorder error'))
|
||||
resolveStop = null
|
||||
rejectStop = null
|
||||
}
|
||||
|
||||
mediaRecorder.start(100) // collect in 100ms chunks
|
||||
recording.value = true
|
||||
}
|
||||
|
||||
function stopRecording(): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!mediaRecorder || !recording.value) {
|
||||
reject(new Error('Not recording'))
|
||||
return
|
||||
}
|
||||
resolveStop = resolve
|
||||
rejectStop = reject
|
||||
mediaRecorder.stop()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
recording: readonly(recording),
|
||||
error: readonly(error),
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
stream: readonly(streamRef) as Readonly<Ref<MediaStream | null>>,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user