Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea9b86f8e | ||
|
|
9b3bd02b0b | ||
|
|
0be93d088b | ||
|
|
832e7265c9 | ||
|
|
d1e295165a | ||
|
|
969521ac3f | ||
|
|
908764873f | ||
|
|
043d28539e | ||
|
|
3efbf75179 | ||
|
|
b8229eb9b5 | ||
|
|
7bf4a801b3 | ||
|
|
3e8fe2d06d | ||
|
|
60eb101015 | ||
|
|
72d123b9a0 | ||
|
|
9bf8e508c7 | ||
|
|
d3e79edb9e | ||
|
|
ea371b8085 | ||
|
|
c8a7160ef2 | ||
|
|
49732b02f3 | ||
|
|
ec3f96dab1 | ||
|
|
b279f16ba0 | ||
|
|
4dc04b21d6 | ||
|
|
bb679d02be | ||
|
|
716bca0fcb | ||
|
|
1092941c45 | ||
|
|
4c82d551ea | ||
|
|
27db599444 | ||
|
|
3983c30fe1 | ||
|
|
9b7a04431c | ||
|
|
bf7e07ead9 | ||
|
|
876e72671a | ||
|
|
0bf63cf8a8 | ||
|
|
eb8e562565 | ||
|
|
1a58ef993e | ||
|
|
7c6b8d7c37 | ||
|
|
3a5c6d056c | ||
|
|
c8db5475d3 | ||
|
|
876a283d47 | ||
|
|
c80070f5c3 | ||
|
|
a58a91d8b8 | ||
|
|
e5e509081c | ||
|
|
a2e5eb111d | ||
|
|
cf50f20714 | ||
|
|
fd338384b7 | ||
|
|
de738274fa | ||
|
|
9d13d6a430 | ||
|
|
060a2f19a9 | ||
|
|
6183fb62c8 | ||
|
|
9e66c12eb7 | ||
|
|
e1c2a090fb |
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
.worktrees
|
||||||
.git
|
.git
|
||||||
deploy
|
deploy
|
||||||
docs
|
docs
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ OPENPLANTBOOK_CLIENT_SECRET=
|
|||||||
|
|
||||||
# LLM assistant (OpenAI-compatible — Ollama, vLLM, LiteLLM, etc.)
|
# LLM assistant (OpenAI-compatible — Ollama, vLLM, LiteLLM, etc.)
|
||||||
# Leave LLM_BASE_URL unset to use the built-in mock provider (CI / local without a model).
|
# Leave LLM_BASE_URL unset to use the built-in mock provider (CI / local without a model).
|
||||||
|
# Voice input uses POST {LLM_BASE_URL}/audio/transcriptions (Whisper-compatible).
|
||||||
|
# Photo messages use vision via the same chat/completions endpoint.
|
||||||
LLM_PROVIDER=openai
|
LLM_PROVIDER=openai
|
||||||
LLM_BASE_URL=
|
LLM_BASE_URL=
|
||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
LLM_MODEL=llama3.2
|
LLM_MODEL=llama3.2
|
||||||
|
# IANA timezone for assistant relative dates ("Thursday at 2"). Falls back to TZ, then America/Chicago.
|
||||||
|
HOUSEHOLD_TIMEZONE=America/Chicago
|
||||||
|
# Optional override for agent tool → /api/v1 self-calls (defaults to http://127.0.0.1:$PORT).
|
||||||
|
# INTERNAL_API_BASE_URL=http://127.0.0.1:3000
|
||||||
|
|||||||
+13
-20
@@ -3,10 +3,21 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "**/*.md"
|
||||||
|
- "docs/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- "**/*.md"
|
||||||
|
- "docs/**"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
|
if: "github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore: release')"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -15,6 +26,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare pnpm@10.33.3 --activate
|
corepack prepare pnpm@10.33.3 --activate
|
||||||
|
mkdir -p /pnpm-store
|
||||||
|
pnpm config set store-dir /pnpm-store
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -27,23 +40,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: pnpm format:check
|
run: pnpm format:check
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Enable pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.33.3 --activate
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
env:
|
|
||||||
DATABASE_URL: postgres://ci_user:ci_password@localhost:5432/ci_database
|
|
||||||
AUTH_SECRET: ci-auth-secret-for-build
|
|
||||||
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
name: Release Image
|
name: Release Image
|
||||||
|
|
||||||
|
# Primary release pipeline — pushes to registry.ginnoir.com (prod pulls this).
|
||||||
|
# GitHub Actions .github/workflows/release.yml mirrors to GHCR as backup only.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -24,8 +31,31 @@ jobs:
|
|||||||
echo "image=registry.ginnoir.com/ginnoir/famapp"
|
echo "image=registry.ginnoir.com/ginnoir/famapp"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Install docker
|
- name: Ensure docker CLI + buildx
|
||||||
run: apt-get update -qq && apt-get install -y -qq docker.io
|
run: |
|
||||||
|
# Debian's docker.io package ships no buildx plugin, which the
|
||||||
|
# registry-cache build below requires. Install Docker's official
|
||||||
|
# CLI + buildx plugin so the build works regardless of what the
|
||||||
|
# runner image happens to provide.
|
||||||
|
if docker buildx version >/dev/null 2>&1; then
|
||||||
|
echo "docker + buildx already available"
|
||||||
|
docker version
|
||||||
|
docker buildx version
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq ca-certificates curl gnupg
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker-ce-cli docker-buildx-plugin
|
||||||
|
docker version
|
||||||
|
docker buildx version
|
||||||
|
|
||||||
- name: Login to registry
|
- name: Login to registry
|
||||||
run: |
|
run: |
|
||||||
@@ -33,16 +63,23 @@ jobs:
|
|||||||
--username "${{ secrets.REGISTRY_PUSH_USERNAME }}" \
|
--username "${{ secrets.REGISTRY_PUSH_USERNAME }}" \
|
||||||
--password-stdin
|
--password-stdin
|
||||||
|
|
||||||
- name: Build image
|
- name: Set up buildx
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker buildx create --name famapp-builder --use 2>/dev/null || docker buildx use famapp-builder
|
||||||
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" \
|
docker buildx inspect --bootstrap
|
||||||
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}" \
|
|
||||||
-t "${{ steps.meta.outputs.image }}:latest" \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push image
|
- name: Build and push image
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ steps.meta.outputs.image }}
|
||||||
|
VERSION: ${{ steps.meta.outputs.version }}
|
||||||
|
MAJOR_MINOR: ${{ steps.meta.outputs.major_minor }}
|
||||||
|
CACHE: registry.ginnoir.com/ginnoir/famapp:buildcache
|
||||||
run: |
|
run: |
|
||||||
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}"
|
docker buildx build \
|
||||||
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}"
|
--push \
|
||||||
docker push "${{ steps.meta.outputs.image }}:latest"
|
--tag "${IMAGE}:${VERSION}" \
|
||||||
|
--tag "${IMAGE}:${MAJOR_MINOR}" \
|
||||||
|
--tag "${IMAGE}:latest" \
|
||||||
|
--cache-from "type=registry,ref=${CACHE}" \
|
||||||
|
--cache-to "type=registry,ref=${CACHE},mode=max" \
|
||||||
|
.
|
||||||
|
|||||||
+11
-30
@@ -3,10 +3,21 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "**/*.md"
|
||||||
|
- "docs/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- "**/*.md"
|
||||||
|
- "docs/**"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
|
if: "github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore: release')"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -27,33 +38,3 @@ jobs:
|
|||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
|
|
||||||
- run: pnpm format:check
|
- run: pnpm format:check
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10.33.3
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: .next/cache
|
|
||||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
|
||||||
${{ runner.os }}-nextjs-
|
|
||||||
|
|
||||||
- run: pnpm build
|
|
||||||
env:
|
|
||||||
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
|
|
||||||
AUTH_SECRET: ci-placeholder-secret
|
|
||||||
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
name: Release
|
name: Release (GHCR mirror)
|
||||||
|
|
||||||
|
# Temporary backup mirror only. Production pulls from registry.ginnoir.com via
|
||||||
|
# .gitea/workflows/release.yml — do not deploy from ghcr.io directly.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ src/
|
|||||||
### Core primitives every module gets
|
### Core primitives every module gets
|
||||||
|
|
||||||
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
|
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
|
||||||
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed.
|
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
|
||||||
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
|
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
|
||||||
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/<token>`. Generic.
|
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/<token>`. Generic.
|
||||||
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
|
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
|
||||||
|
|||||||
+164
@@ -1,5 +1,169 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.6.10](https://github.com/ginnoir/famapp/compare/v0.6.9...v0.6.10) (2026-07-18)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- open notes in view mode with optional editor ([9b3bd02](https://github.com/ginnoir/famapp/commit/9b3bd02b0bafc89a514ca8fd4434f9659b9ee4c2))
|
||||||
|
|
||||||
|
## [0.6.9](https://github.com/ginnoir/famapp/compare/v0.6.8...v0.6.9) (2026-07-18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- build share urls from auth_url instead of localhost ([832e726](https://github.com/ginnoir/famapp/commit/832e7265c9354edf86f28f72f129ee34da8c3d04))
|
||||||
|
|
||||||
|
## [0.6.8](https://github.com/ginnoir/famapp/compare/v0.6.7...v0.6.8) (2026-07-18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- restore share and note scrolling on ios pwa ([969521a](https://github.com/ginnoir/famapp/commit/969521ac3f527a939988053b453881ed3560eb61))
|
||||||
|
|
||||||
|
## [0.6.7](https://github.com/ginnoir/famapp/compare/v0.6.6...v0.6.7) (2026-07-13)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **garden:** add ikea cabinet and acrylic case container types ([043d285](https://github.com/ginnoir/famapp/commit/043d28539ec08dd46a097970b4e876bfb1ba7aa1))
|
||||||
|
|
||||||
|
## [0.6.6](https://github.com/ginnoir/famapp/compare/v0.6.5...v0.6.6) (2026-07-13)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **garden:** add carnivore plant category ([b8229eb](https://github.com/ginnoir/famapp/commit/b8229eb9b5550cde98e5446615873e727ead05a8))
|
||||||
|
|
||||||
|
## [0.6.5](https://github.com/ginnoir/famapp/compare/v0.6.4...v0.6.5) (2026-07-09)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **agent:** call api v1 via loopback instead of public url ([3e8fe2d](https://github.com/ginnoir/famapp/commit/3e8fe2d06dc31d3a1058b4b8942c46b1220a6b4c))
|
||||||
|
|
||||||
|
## [0.6.4](https://github.com/ginnoir/famapp/compare/v0.6.3...v0.6.4) (2026-07-09)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **agent:** stop tool thrash after writes and log rounds ([72d123b](https://github.com/ginnoir/famapp/commit/72d123b9a0f67589d92614a8d2138912585b0d79))
|
||||||
|
|
||||||
|
## [0.6.3](https://github.com/ginnoir/famapp/compare/v0.6.2...v0.6.3) (2026-07-09)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **agent:** raise tool rounds and ease calendar creates ([d3e79ed](https://github.com/ginnoir/famapp/commit/d3e79edb9ef0b931fa9d6e6629dad2677d661741))
|
||||||
|
|
||||||
|
## [0.6.2](https://github.com/ginnoir/famapp/compare/v0.6.1...v0.6.2) (2026-07-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **agent:** add assistant model selector ([4c82d55](https://github.com/ginnoir/famapp/commit/4c82d551ea1f0c8091a3ffc31935a8d22855bbc0))
|
||||||
|
- **agent:** add llm model discovery helper ([0bf63cf](https://github.com/ginnoir/famapp/commit/0bf63cf8a83a1adca58fa9e8bf42f16dac60687b))
|
||||||
|
- **agent:** expose available assistant models ([27db599](https://github.com/ginnoir/famapp/commit/27db599444fc0bb80d5a1650e2f5b5ba4f695c8e))
|
||||||
|
- **agent:** let users choose model route ([ec3f96d](https://github.com/ginnoir/famapp/commit/ec3f96dab170fb2333780438bdec3628cfc58116))
|
||||||
|
- **agent:** persist assistant model preference ([bf7e07e](https://github.com/ginnoir/famapp/commit/bf7e07ead91dd8c428f06551afb2113ce838cc82))
|
||||||
|
- **agent:** refresh assistant model catalog ([b279f16](https://github.com/ginnoir/famapp/commit/b279f16ba0e1537d2752d30bf5e565f4ede400af))
|
||||||
|
- **agent:** route chat through selected model ([9b7a044](https://github.com/ginnoir/famapp/commit/9b7a04431cb12609cbcd766e05fbc411599e0b34))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **agent:** hide unrelated models for alias fallback ([4dc04b2](https://github.com/ginnoir/famapp/commit/4dc04b21d63aaef7c773f238602f06edbbf03509))
|
||||||
|
- **agent:** improve model selector legibility ([bb679d0](https://github.com/ginnoir/famapp/commit/bb679d02be28440acbc860146cbcb3809224aa09))
|
||||||
|
- **agent:** move route selector to settings ([49732b0](https://github.com/ginnoir/famapp/commit/49732b02f31cbef57e6cd3b577bee6dbd998e029))
|
||||||
|
- **agent:** reject padded assistant model ids ([3983c30](https://github.com/ginnoir/famapp/commit/3983c30fe1b8a324f7d5826df0b24506fe054e50))
|
||||||
|
- **agent:** use native model selector ([716bca0](https://github.com/ginnoir/famapp/commit/716bca0fcba8f2026c814f668bf873cf61a0ad16))
|
||||||
|
- **agent:** validate assistant model requests ([876e726](https://github.com/ginnoir/famapp/commit/876e72671a0b82b579a9783eb86f452a4a026a52))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- plan assistant model selector ([eb8e562](https://github.com/ginnoir/famapp/commit/eb8e5625656a1e3fe97e19b0bb2514660cf0c5f6))
|
||||||
|
- specify assistant model selector ([1a58ef9](https://github.com/ginnoir/famapp/commit/1a58ef993e62ea2855d7d5aafc17446a0e91c33c))
|
||||||
|
|
||||||
|
## [0.6.1](https://github.com/ginnoir/famapp/compare/v0.6.0...v0.6.1) (2026-07-05)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **agent:** add voice input and photo attachments to assistant ([c8db547](https://github.com/ginnoir/famapp/commit/c8db5475d314b239246e4786e362bb044992d660))
|
||||||
|
|
||||||
|
## [0.6.0](https://github.com/ginnoir/famapp/compare/v0.5.6...v0.6.0) (2026-07-05)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- per-user assistant name and system prompt customization ([e5e5090](https://github.com/ginnoir/famapp/commit/e5e509081c611b8f84e2e529f82bfa5bd7f9da52))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- record v0.5.6 prod deploy and migration repair ([cf50f20](https://github.com/ginnoir/famapp/commit/cf50f2071442948b8d8493b464f74bc05f3900b2))
|
||||||
|
- registry is primary image source, ghcr is backup mirror ([a2e5eb1](https://github.com/ginnoir/famapp/commit/a2e5eb111d44d7781bc3852a6d4346cb10387b52))
|
||||||
|
|
||||||
|
## [0.5.6](https://github.com/ginnoir/famapp/compare/v0.5.5...v0.5.6) (2026-07-05)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- add build-time env vars to docker image build ([de73827](https://github.com/ginnoir/famapp/commit/de738274face84f05a029718cf011c0ef455371d))
|
||||||
|
|
||||||
|
## [0.5.5](https://github.com/ginnoir/famapp/compare/v0.5.4...v0.5.5) (2026-07-05)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **agent:** add llm assistant chat with api tools (task 88) ([4a924a4](https://github.com/ginnoir/famapp/commit/4a924a41072a43484c009757a1644cd42309fc0e))
|
||||||
|
- api v1 garden bangs routes and openapi ([e8d13be](https://github.com/ginnoir/famapp/commit/e8d13bede81b5a778118420292e7f35e4e54788e))
|
||||||
|
- api v1 routes for calendar lists and notes ([d4304b0](https://github.com/ginnoir/famapp/commit/d4304b005cf86d6d3c14497afbb6e0973a2a8d54))
|
||||||
|
- bang edit and delete ([753f653](https://github.com/ginnoir/famapp/commit/753f653b89f244b263023dbe744e30c5eaf2893f))
|
||||||
|
- household api token auth foundation ([ea5d1d0](https://github.com/ginnoir/famapp/commit/ea5d1d050ca5e0aa8a532330448e2f2cdbda0c39))
|
||||||
|
- journal dashboard widgets, agent polish, and edit-mode live previews ([a09747c](https://github.com/ginnoir/famapp/commit/a09747c3142e089199bfd3839631c9d8573175f0))
|
||||||
|
- **journal:** add per-user mood journal module (task 86) ([04ae809](https://github.com/ginnoir/famapp/commit/04ae809e0710c3930b0056fbb1f869fc70223131))
|
||||||
|
- **notes:** rich-text editor with tiptap (task 85) ([a4be5d5](https://github.com/ginnoir/famapp/commit/a4be5d5061d41cb9b4ba37fc89adfb68c00466f7))
|
||||||
|
- p2 batch 28-31 reminders lists comments bang stats ([e1c2a09](https://github.com/ginnoir/famapp/commit/e1c2a090fb379dcd5d453a658a74a5085b473281)), closes [#28](https://github.com/ginnoir/famapp/issues/28) [#29](https://github.com/ginnoir/famapp/issues/29) [#30](https://github.com/ginnoir/famapp/issues/30)
|
||||||
|
- shared back navigation on detail pages ([d090200](https://github.com/ginnoir/famapp/commit/d090200ec8950d43fe1e1f71bf1248cf71759199))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- dashboard edit mode renders live widgets ([7eeb2f1](https://github.com/ginnoir/famapp/commit/7eeb2f15bcab4cfbb33ba1138449b74d01ff5953))
|
||||||
|
- dashboard edit previews, migration journal, and notes comments ([6183fb6](https://github.com/ginnoir/famapp/commit/6183fb62c88a51ff967da428edfbf1b8b6d70d84))
|
||||||
|
- garden container plant count ([700ee29](https://github.com/ginnoir/famapp/commit/700ee29f83dd8e6b3580168d5944672c3441a8da))
|
||||||
|
- quick add opens create ui ([68a573c](https://github.com/ginnoir/famapp/commit/68a573c4d656f4a2c25d5b15d4f7ff1849a76bdf))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- accept adr 0004 rich-text editor decisions ([67f6752](https://github.com/ginnoir/famapp/commit/67f67525ff7732868839e0e615078522b57e9ddf))
|
||||||
|
- accept adr 0005 journal module decisions ([8e2ddd6](https://github.com/ginnoir/famapp/commit/8e2ddd6b7216381e480a657980e5619d488d01c9))
|
||||||
|
- accept adr 0006 api and agent architecture ([2c0c315](https://github.com/ginnoir/famapp/commit/2c0c31540ed276582984e6dd45f37d7672e60b63))
|
||||||
|
- add backlog triage implementation plan ([310ba48](https://github.com/ginnoir/famapp/commit/310ba48e335409e108e8b4387acee234db784adb))
|
||||||
|
- add commit hash for p2 batch 28-31 in status ([9e66c12](https://github.com/ginnoir/famapp/commit/9e66c12eb7afc4dcb6e0203f0e32a5ce8d0200f1))
|
||||||
|
- add post-v0.1 backlog triage design ([72e868d](https://github.com/ginnoir/famapp/commit/72e868dda1f96dd132d3e1b6bfaa54ed1c5883ef))
|
||||||
|
- complete Phase 9 backlog triage (issues map, briefs, ADRs) ([76f6854](https://github.com/ginnoir/famapp/commit/76f68548b2609aa8e672dba563acf90a30ba09ed)), closes [1-#37](https://github.com/ginnoir/1-/issues/37)
|
||||||
|
- mark task 82 done in status ([1a03763](https://github.com/ginnoir/famapp/commit/1a03763e1bf992766dfd7891e3eb815534e92038))
|
||||||
|
- mark task 83 done in status ([1a1c080](https://github.com/ginnoir/famapp/commit/1a1c080d18e2c22838949dfed8e77a54fab1dcb5))
|
||||||
|
- mark task 84 done, bugs batch complete ([5e711cf](https://github.com/ginnoir/famapp/commit/5e711cfe0d7bd779114af19fe111375abe269953))
|
||||||
|
- mark task 87 done in status ([fb67692](https://github.com/ginnoir/famapp/commit/fb67692a08973f9e6e14f7c2ec336cad5bdaf777))
|
||||||
|
- record journal polish commit in status ([7714b11](https://github.com/ginnoir/famapp/commit/7714b1187c76d1d324573bc8819568751f991b09))
|
||||||
|
|
||||||
|
* fix: dashboard edit previews, migration journal, and notes comments (6183fb6)
|
||||||
|
* docs: add commit hash for p2 batch 28-31 in status (9e66c12)
|
||||||
|
* feat: p2 batch 28-31 reminders lists comments bang stats (e1c2a09)
|
||||||
|
* docs: record journal polish commit in status (7714b11)
|
||||||
|
* feat: journal dashboard widgets, agent polish, and edit-mode live previews (a09747c)
|
||||||
|
* feat(agent): add llm assistant chat with api tools (task 88) (4a924a4)
|
||||||
|
* feat(journal): add per-user mood journal module (task 86) (04ae809)
|
||||||
|
* docs: accept adr 0005 journal module decisions (8e2ddd6)
|
||||||
|
* feat(notes): rich-text editor with tiptap (task 85) (a4be5d5)
|
||||||
|
* docs: accept adr 0004 rich-text editor decisions (67f6752)
|
||||||
|
* docs: mark task 87 done in status (fb67692)
|
||||||
|
* feat: api v1 garden bangs routes and openapi (e8d13be)
|
||||||
|
* feat: api v1 routes for calendar lists and notes (d4304b0)
|
||||||
|
* feat: household api token auth foundation (ea5d1d0)
|
||||||
|
* docs: accept adr 0006 api and agent architecture (2c0c315)
|
||||||
|
* docs: mark task 84 done, bugs batch complete (5e711cf)
|
||||||
|
* feat: shared back navigation on detail pages (d090200)
|
||||||
|
* docs: mark task 83 done in status (1a1c080)
|
||||||
|
* feat: bang edit and delete (753f653)
|
||||||
|
* docs: mark task 82 done in status (1a03763)
|
||||||
|
* fix: garden container plant count (700ee29)
|
||||||
|
* fix: dashboard edit mode renders live widgets (7eeb2f1)
|
||||||
|
* test: cover all quick-add create dialogs in e2e (02b6ec6)
|
||||||
|
* fix: quick add opens create ui (68a573c)
|
||||||
|
* docs: complete Phase 9 backlog triage (issues map, briefs, ADRs) (76f6854)
|
||||||
|
* chore: ignore .worktrees for agent isolation (5dfc8b8)
|
||||||
|
* docs: add backlog triage implementation plan (310ba48)
|
||||||
|
* docs: add post-v0.1 backlog triage design (72e868d)
|
||||||
|
* chore: vendor react-best-practices agent skill for cursor agents (3d825fb)
|
||||||
|
|
||||||
## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04)
|
## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ src/
|
|||||||
### Core primitives every module gets
|
### Core primitives every module gets
|
||||||
|
|
||||||
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
|
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
|
||||||
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed.
|
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
|
||||||
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
|
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
|
||||||
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/<token>`. Generic.
|
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/<token>`. Generic.
|
||||||
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
|
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ WORKDIR /app
|
|||||||
# CI=true prevents pnpm from prompting for TTY confirmation when removing modules dir
|
# CI=true prevents pnpm from prompting for TTY confirmation when removing modules dir
|
||||||
ENV CI=true
|
ENV CI=true
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV DATABASE_URL=postgres://build:build@localhost:5432/build
|
||||||
|
ENV AUTH_SECRET=build-time-placeholder
|
||||||
|
# Build-only placeholder. Runtime public URL is AUTH_URL from stack.env — never prefer this.
|
||||||
|
ENV NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
ENV AUTH_URL=http://localhost:3000
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm install --offline --frozen-lockfile
|
RUN pnpm install --offline --frozen-lockfile
|
||||||
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
|
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Self-hosted family coordination web app. Shared calendar, lists, notes, and gard
|
|||||||
|
|
||||||
[](https://github.com/ginnoir/famapp/actions/workflows/ci.yml)
|
[](https://github.com/ginnoir/famapp/actions/workflows/ci.yml)
|
||||||
[](https://github.com/ginnoir/famapp/releases)
|
[](https://github.com/ginnoir/famapp/releases)
|
||||||
[](https://github.com/ginnoir/famapp/pkgs/container/famapp)
|
[](https://gitea.ginnoir.com/ginnoir/famapp)
|
||||||
[](https://nodejs.org)
|
[](https://nodejs.org)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
|||||||
|
|
||||||
- **Journal + dashboard polish** (commit `a09747c`, follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open.
|
- **Journal + dashboard polish** (commit `a09747c`, follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open.
|
||||||
|
|
||||||
|
- **89 — Calendar reminders overhaul** (Gitea #28, commit `e1c2a09`). Multiple reminders per event via `syncCalendarEventReminders`; `ReminderPicker` with presets + custom offsets; migration `0022_multiple_reminders.sql` drops one-per-entity unique index, adds `offset_minutes` + `users.default_event_reminder_offsets`. Settings → Notifications default reminders editor. Create/edit/quick-add calendar flows updated; drag-reschedule resyncs existing offsets.
|
||||||
|
|
||||||
|
- **90 — Lists index inline add + edit** (Gitea #29, commit `e1c2a09`). `/lists` cards: inline task add row, pencil edit for name/type via `updateListProperties`.
|
||||||
|
|
||||||
|
- **91 — Comments on lists and tasks** (Gitea #30, commit `e1c2a09`). Generic `comments` table + `_core/comments.ts`; reusable `EntityComments` on list detail (list + per-item compact threads). Migration `0023_comments.sql`.
|
||||||
|
|
||||||
|
- **92 — Bang stats dashboard widget** (Gitea #31, commit `e1c2a09`). `bangs.stats` widget with monthly/yearly counts, average days between bangs, recent month breakdown.
|
||||||
|
|
||||||
|
- **P2 batch follow-ups** (release **v0.5.6**, prod deployed 2026-07-05). Migration journal entries for `0022`/`0023`; dashboard edit previews keyed by placement index (`bangs.stats` live in edit mode); notes comments; Dockerfile build-time env vars (fixes release CI). **Prod:** pulled `ghcr.io/ginnoir/famapp:latest` → tagged `registry.ginnoir.com/ginnoir/famapp:latest`, recreated `famapp` container. Prod DB had 21/24 migration rows (0022/0023 applied out of order); applied missing `0019`–`0021` schema + journal rows — now 24 migrations, `comments` + `default_event_reminder_offsets` confirmed.
|
||||||
|
|
||||||
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
|
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
|
||||||
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
|
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
|
||||||
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
|
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
|
||||||
@@ -73,7 +83,7 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
|
|||||||
|
|
||||||
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 7–9, 11–12, 15–19.
|
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 7–9, 11–12, 15–19.
|
||||||
|
|
||||||
**How to resume:** Phase 9 P1 batch complete. Next work is P2/P3 Gitea backlog or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
|
**How to resume:** P2 batch #28–#31 complete (released + deployed v0.5.6 on prod). Next: remaining P2/P3 Gitea backlog (#32+) or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
|
||||||
|
|
||||||
## Development login/testing notes
|
## Development login/testing notes
|
||||||
|
|
||||||
@@ -101,5 +111,6 @@ P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-m
|
|||||||
- Repo: https://github.com/ginnoir/famapp (HTTPS remote on `origin`).
|
- Repo: https://github.com/ginnoir/famapp (HTTPS remote on `origin`).
|
||||||
- Local dev tooling installed: Node 22+, pnpm 10.33.3.
|
- Local dev tooling installed: Node 22+, pnpm 10.33.3.
|
||||||
- `.env` is **not** committed; copy `.env.example` → `.env` when needed.
|
- `.env` is **not** committed; copy `.env.example` → `.env` when needed.
|
||||||
|
- Assistant relative dates use `HOUSEHOLD_TIMEZONE` (default `America/Chicago` if unset).
|
||||||
- VS Code recommended extensions in `.vscode/extensions.json`; copy `.vscode/settings.json.example` → `.vscode/settings.json` for the workspace defaults.
|
- VS Code recommended extensions in `.vscode/extensions.json`; copy `.vscode/settings.json.example` → `.vscode/settings.json` for the workspace defaults.
|
||||||
- Memory files (cross-session, only seen by Claude): `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\`.
|
- Memory files (cross-session, only seen by Claude): `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\`.
|
||||||
|
|||||||
+5
-1
@@ -15,12 +15,16 @@ pnpm release:patch # or :minor / :major
|
|||||||
# Requires GITHUB_TOKEN in .env
|
# Requires GITHUB_TOKEN in .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Gitea Actions (`release.yml`) then builds and pushes the Docker image to the self-hosted registry:
|
Gitea Actions (`.gitea/workflows/release.yml`) builds and pushes the Docker image to the **self-hosted registry** (production source of truth):
|
||||||
|
|
||||||
- `registry.ginnoir.com/ginnoir/famapp:v0.x.y`
|
- `registry.ginnoir.com/ginnoir/famapp:v0.x.y`
|
||||||
- `registry.ginnoir.com/ginnoir/famapp:0.x` (minor alias)
|
- `registry.ginnoir.com/ginnoir/famapp:0.x` (minor alias)
|
||||||
- `registry.ginnoir.com/ginnoir/famapp:latest`
|
- `registry.ginnoir.com/ginnoir/famapp:latest`
|
||||||
|
|
||||||
|
GitHub Actions (`.github/workflows/release.yml`) mirrors the same tag to **GHCR** as a temporary backup only — prod must not pull from `ghcr.io`.
|
||||||
|
|
||||||
|
Wait for the Gitea Actions run to finish (or confirm the new digest on the registry) before redeploying.
|
||||||
|
|
||||||
## Deploying a release
|
## Deploying a release
|
||||||
|
|
||||||
On the home server, in `/srv/famapp/deploy/`:
|
On the home server, in `/srv/famapp/deploy/`:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
|||||||
|
# Assistant model selector design
|
||||||
|
|
||||||
|
Date: 2026-07-08
|
||||||
|
Status: approved for planning
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The AI assistant chat currently uses one environment-configured model through `LLM_MODEL`.
|
||||||
|
The chat UI posts messages to `/api/agent/chat`, and the server creates the OpenAI-compatible
|
||||||
|
client without any request-time model choice.
|
||||||
|
|
||||||
|
ginnoir wants a model selector in the assistant chat. The selector should discover available
|
||||||
|
models from the configured OpenAI-compatible provider and save the selected model as the user's
|
||||||
|
default.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Show a compact model selector in the assistant chat panel.
|
||||||
|
- Discover models from the provider's `/models` endpoint server-side.
|
||||||
|
- Persist the selected model per user so it works across browser sessions and devices.
|
||||||
|
- Keep `LLM_MODEL` as the fallback when discovery fails, no model is saved, or the saved model
|
||||||
|
is no longer available.
|
||||||
|
- Preserve mock-provider behavior in CI and local setups without `LLM_BASE_URL`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Model hosting, training, or fine-tuning.
|
||||||
|
- Multiple LLM providers in the same deployment.
|
||||||
|
- Per-message experimental settings beyond selecting the model ID.
|
||||||
|
- Exposing arbitrary browser-supplied model IDs to the provider.
|
||||||
|
|
||||||
|
## User experience
|
||||||
|
|
||||||
|
When the assistant bubble opens, the chat panel loads available model IDs from the server.
|
||||||
|
The selector appears near the existing assistant status and clear-chat controls. It should be
|
||||||
|
visible but compact enough not to reduce the message area materially.
|
||||||
|
|
||||||
|
Changing the selector immediately saves the user's default model. The next message uses that
|
||||||
|
model, and future assistant sessions start with the saved selection when it is still available.
|
||||||
|
|
||||||
|
If model discovery fails, the panel remains usable with the `LLM_MODEL` fallback and shows a
|
||||||
|
muted status that model discovery is unavailable. If the saved model has disappeared from the
|
||||||
|
provider, the server and UI fall back to `LLM_MODEL`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
`getLlmConfig()` remains the source for provider, base URL, API key, and fallback model.
|
||||||
|
No additional allowlist environment variable is required because model IDs come from the
|
||||||
|
provider's OpenAI-compatible `/models` endpoint.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
Add nullable `assistant_model` storage to `users`.
|
||||||
|
|
||||||
|
The existing assistant preference loader should return:
|
||||||
|
|
||||||
|
- assistant enabled state
|
||||||
|
- assistant display name
|
||||||
|
- assistant system prompt
|
||||||
|
- saved assistant model ID
|
||||||
|
|
||||||
|
The value is nullable. `null` means "use the environment fallback model."
|
||||||
|
|
||||||
|
### Model discovery API
|
||||||
|
|
||||||
|
Add `GET /api/agent/models`.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Require the same authenticated user/session or API auth shape as the chat endpoint.
|
||||||
|
- Require assistant access to be enabled for the user.
|
||||||
|
- If `LLM_BASE_URL` is missing or the provider is mock, return the fallback model as the only
|
||||||
|
available model.
|
||||||
|
- Fetch `${LLM_BASE_URL}/models` with `Authorization: Bearer ${LLM_API_KEY}` when configured.
|
||||||
|
- Accept OpenAI-style payloads with a top-level `data` array.
|
||||||
|
- Normalize each model to `{ id: string, label: string }`, using the ID as the label.
|
||||||
|
- Deduplicate, sort consistently, and include the fallback model if the provider omitted it.
|
||||||
|
- If discovery fails, return the fallback model plus a degraded status instead of failing the
|
||||||
|
chat UI.
|
||||||
|
|
||||||
|
The response should include enough metadata for the UI:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"models": [{ "id": "llama3.2", "label": "llama3.2" }],
|
||||||
|
"selectedModel": "llama3.2",
|
||||||
|
"fallbackModel": "llama3.2",
|
||||||
|
"degraded": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Saving the default model
|
||||||
|
|
||||||
|
Add a server action for updating the user's assistant model, matching the existing assistant
|
||||||
|
settings actions. The update path must:
|
||||||
|
|
||||||
|
- Accept a model ID string or `null`.
|
||||||
|
- Validate length and basic shape before touching the database.
|
||||||
|
- Validate the requested model against the current discovered model list.
|
||||||
|
- Save `null` when the selected model matches the fallback so `LLM_MODEL` changes take effect for
|
||||||
|
users who have not chosen a non-default model.
|
||||||
|
- Revalidate assistant surfaces after saving.
|
||||||
|
|
||||||
|
### Chat request flow
|
||||||
|
|
||||||
|
Extend `clientChatInputSchema` with optional `model`.
|
||||||
|
|
||||||
|
The chat route should:
|
||||||
|
|
||||||
|
- Parse `model` from the request body.
|
||||||
|
- Resolve the effective model from request model, saved user default, and fallback model.
|
||||||
|
- Validate request model and saved user default against discovered models.
|
||||||
|
- Reject an invalid request model with `400`.
|
||||||
|
- Silently fall back when the saved user default is no longer available.
|
||||||
|
- Pass the effective model into `runAgentChat`.
|
||||||
|
|
||||||
|
`runAgentChat` should accept an optional model override. `createLlmClient` should support an
|
||||||
|
override object or equivalent path that replaces only the model while preserving the configured
|
||||||
|
provider, base URL, and API key.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Missing auth: `401`.
|
||||||
|
- Assistant disabled: `403`.
|
||||||
|
- Invalid posted model: `400`.
|
||||||
|
- Provider `/models` failure: return fallback model from the model-discovery API with
|
||||||
|
`degraded: true`; do not block chat startup.
|
||||||
|
- LLM completion failure after a valid model is selected: keep the existing chat error behavior.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests:
|
||||||
|
|
||||||
|
- Model discovery normalizes OpenAI-compatible `/models` responses.
|
||||||
|
- Discovery falls back to `LLM_MODEL` for mock or failed provider states.
|
||||||
|
- Chat input schema accepts an optional valid model string and rejects invalid shapes.
|
||||||
|
- Chat route rejects a model not returned by discovery.
|
||||||
|
- `runAgentChat` passes the effective model override into the LLM client path.
|
||||||
|
|
||||||
|
E2E smoke:
|
||||||
|
|
||||||
|
- After assistant opt-in, opening the assistant shows the model selector.
|
||||||
|
- Sending a message still renders the user message and assistant response with the mock provider.
|
||||||
|
|
||||||
|
## Rollout notes
|
||||||
|
|
||||||
|
This is additive. Existing deployments without a provider `/models` endpoint continue to use
|
||||||
|
`LLM_MODEL`. The database migration is nullable, so existing users keep current behavior until
|
||||||
|
they choose a model.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 89 — Calendar reminders overhaul
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the single "Remind me 30 min before" checkbox with multiple reminders, presets, and custom offsets.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Store multiple reminder rows per calendar event in the generic `reminders` table (drop one-per-entity unique constraint).
|
||||||
|
- Offsets are minutes before event start; presets + custom value/unit in UI.
|
||||||
|
- Per-user default offsets live on `users.default_event_reminder_offsets` (jsonb, default `[30]`).
|
||||||
|
- Notes/garden keep single-reminder behavior via delete-then-insert in `scheduleReminder`.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 41 (reminders engine)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Migration: drop `reminders_entity_unique`, add `offset_minutes`, add user default column.
|
||||||
|
- `_core/reminders.ts`: `syncRemindersForEntity`, refactor `scheduleReminder`.
|
||||||
|
- Shared `ReminderPicker` component + offset helpers.
|
||||||
|
- Calendar create/edit + quick-add dialogs; sync reminders on create/update/delete.
|
||||||
|
- Optional: settings field for default reminder offsets.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Recurring reminders, snooze.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Users can add/remove multiple reminders on an event
|
||||||
|
- [x] Preset list and custom offset both work
|
||||||
|
- [x] Reminders fire via existing notification bus
|
||||||
|
- [x] Per-user default reminder preferences (stored + applied on new events)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#28](https://gitea.ginnoir.com/ginnoir/famapp/issues/28)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 90 — Lists index: inline task add + list property edit
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
From `/lists`, add tasks and edit list properties without opening the full list page.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Inline add input at the bottom of each list card (Enter to submit, same as detail page).
|
||||||
|
- Inline edit for list name and type via a small edit control on the card header.
|
||||||
|
- Reuse existing `addItem`, `renameList`, and extended `updateList` server actions.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 11 (lists module)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Extend `listUpdateInput` to allow `type` changes.
|
||||||
|
- Update `lists-index.tsx` with add-item row and list property editor.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Add an item to a list from the index
|
||||||
|
- [x] Edit list properties from the index
|
||||||
|
- [x] Changes persist and match detail-page behavior
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#29](https://gitea.ginnoir.com/ginnoir/famapp/issues/29)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 91 — Comments on lists and tasks
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Household members can comment on lists and individual list items using a reusable comment component.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Generic `comments` table in `_core` keyed by `(entity_type, entity_id)`.
|
||||||
|
- Reusable `EntityComments` client component in `src/components/comments/`.
|
||||||
|
- Wired on list detail: list-level thread + per-item expandable comments.
|
||||||
|
- Entity-generic design so notes/other modules can adopt later without schema changes.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 11 (lists module)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Migration: `comments` table.
|
||||||
|
- `_core/comments.ts`: list, add, delete (author-only).
|
||||||
|
- `EntityComments` UI.
|
||||||
|
- Lists detail page integration for `lists.list` and `lists.item`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Comments on notes/calendar (future adoption of same component).
|
||||||
|
- Rich-text comments (plain text only).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Comment on a list
|
||||||
|
- [x] Comment on a task/item
|
||||||
|
- [x] Household members see each other's comments
|
||||||
|
- [x] Component is entity-generic (documented in brief)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#30](https://gitea.ginnoir.com/ginnoir/famapp/issues/30)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 92 — Bang stats dashboard widget
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A second bangs dashboard widget showing monthly/yearly counts and average days between bangs.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Widget id `bangs.stats`, separate from `bangs.counter`.
|
||||||
|
- Aggregates computed server-side from `bang_events.occurred_on`.
|
||||||
|
- Works alongside edit/delete from task 83.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- Bangs module, 83 (edit/delete)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `getBangAggregates` query.
|
||||||
|
- `BangStatsWidget` component.
|
||||||
|
- Manifest registration.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Widget registered and placeable on dashboards
|
||||||
|
- [x] Shows monthly and yearly counts
|
||||||
|
- [x] Shows average days between bangs
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#31](https://gitea.ginnoir.com/ginnoir/famapp/issues/31)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DROP INDEX IF EXISTS "reminders_entity_unique";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "reminders" ADD COLUMN "offset_minutes" integer;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "reminders_entity_idx" ON "reminders" USING btree ("entity_type","entity_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "default_event_reminder_offsets" jsonb DEFAULT '[30]'::jsonb NOT NULL;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"entity_id" uuid NOT NULL,
|
||||||
|
"author_id" uuid NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "comments_entity_idx" ON "comments" USING btree ("entity_type","entity_id","created_at");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "assistant_name" text DEFAULT 'Assistant' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "assistant_system_prompt" text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "assistant_model" text;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "assistant_model_route" text;--> statement-breakpoint
|
||||||
|
UPDATE "users"
|
||||||
|
SET "assistant_model_route" = "assistant_model",
|
||||||
|
"assistant_model" = NULL
|
||||||
|
WHERE "assistant_model" IN ('auto', 'uncensored');
|
||||||
@@ -155,6 +155,41 @@
|
|||||||
"when": 1751754000000,
|
"when": 1751754000000,
|
||||||
"tag": "0021_user_assistant_enabled",
|
"tag": "0021_user_assistant_enabled",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780392000000,
|
||||||
|
"tag": "0022_multiple_reminders",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780393000000,
|
||||||
|
"tag": "0023_comments",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780394000000,
|
||||||
|
"tag": "0024_user_assistant_customization",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783560000000,
|
||||||
|
"tag": "0025_assistant_model",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783561000000,
|
||||||
|
"tag": "0026_assistant_model_route",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ export default tseslint.config(
|
|||||||
ignores: [
|
ignores: [
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
".next/**",
|
".next/**",
|
||||||
|
".worktrees/**",
|
||||||
".claude/**",
|
".claude/**",
|
||||||
".design-tmp/**",
|
".design-tmp/**",
|
||||||
"dist/**",
|
"dist/**",
|
||||||
|
|||||||
+8
-1
@@ -178,7 +178,14 @@ const nextConfig: NextConfig = {
|
|||||||
output: "standalone",
|
output: "standalone",
|
||||||
// Keep pino and pino-pretty as native Node.js requires so their worker-thread
|
// Keep pino and pino-pretty as native Node.js requires so their worker-thread
|
||||||
// transport and stream internals work correctly inside the standalone bundle.
|
// transport and stream internals work correctly inside the standalone bundle.
|
||||||
serverExternalPackages: ["pino", "pino-pretty", "drizzle-orm", "postgres"],
|
serverExternalPackages: [
|
||||||
|
"pino",
|
||||||
|
"pino-pretty",
|
||||||
|
"drizzle-orm",
|
||||||
|
"postgres",
|
||||||
|
"isomorphic-dompurify",
|
||||||
|
"jsdom",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "famapp",
|
"name": "famapp",
|
||||||
"version": "0.5.3",
|
"version": "0.6.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.3",
|
"packageManager": "pnpm@10.33.3",
|
||||||
|
|||||||
@@ -1,35 +1,25 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { apiError, apiJson } from "@/lib/api-handler";
|
import { apiError, apiJson } from "@/lib/api-handler";
|
||||||
import { resolveApiAuth } from "@/lib/api-auth";
|
import { resolveApiAuth } from "@/lib/api-auth";
|
||||||
import { getAssistantEnabled } from "@/lib/assistant-preference";
|
import { getAssistantPreferences, resolveAssistantSystemPrompt } from "@/lib/assistant-preference";
|
||||||
import { isLlmConfigured } from "@/lib/llm";
|
import { isLlmConfigured } from "@/lib/llm";
|
||||||
|
import { listLlmModels, resolveAssistantModel } from "@/lib/llm/models";
|
||||||
|
import { clientChatInputSchema } from "@/modules/agent/messages";
|
||||||
import { encodeSseEvent } from "@/modules/agent/server/progress";
|
import { encodeSseEvent } from "@/modules/agent/server/progress";
|
||||||
import { runAgentChat } from "@/modules/agent/server/run";
|
import { runAgentChat } from "@/modules/agent/server/run";
|
||||||
|
|
||||||
const chatInput = z.object({
|
|
||||||
stream: z.boolean().optional(),
|
|
||||||
messages: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
role: z.enum(["user", "assistant"]),
|
|
||||||
content: z.string().trim().min(1).max(8000),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.min(1)
|
|
||||||
.max(40),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const auth = await resolveApiAuth(request);
|
const auth = await resolveApiAuth(request);
|
||||||
if (!auth?.userId) {
|
if (!auth?.userId) {
|
||||||
return apiError("Unauthorized", 401);
|
return apiError("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistantEnabled = await getAssistantEnabled(auth.userId);
|
const assistant = await getAssistantPreferences(auth.userId);
|
||||||
if (!assistantEnabled) {
|
if (!assistant.enabled) {
|
||||||
return apiError("Assistant not enabled", 403);
|
return apiError("Assistant not enabled", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const systemPrompt = resolveAssistantSystemPrompt(assistant.systemPrompt);
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
@@ -37,11 +27,23 @@ export async function POST(request: Request) {
|
|||||||
return apiError("Invalid JSON body", 400);
|
return apiError("Invalid JSON body", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = chatInput.safeParse(body);
|
const parsed = clientChatInputSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
|
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelList = await listLlmModels({ route: assistant.modelRoute });
|
||||||
|
const modelResolution = resolveAssistantModel({
|
||||||
|
requestedModel: parsed.data.model,
|
||||||
|
savedModel: assistant.model,
|
||||||
|
fallbackModel: modelList.fallbackModel,
|
||||||
|
models: modelList.models,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!modelResolution.ok) {
|
||||||
|
return apiError(modelResolution.error, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.data.stream) {
|
if (parsed.data.stream) {
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
@@ -54,6 +56,8 @@ export async function POST(request: Request) {
|
|||||||
const result = await runAgentChat({
|
const result = await runAgentChat({
|
||||||
messages: parsed.data.messages,
|
messages: parsed.data.messages,
|
||||||
request,
|
request,
|
||||||
|
systemPrompt,
|
||||||
|
model: modelResolution.model,
|
||||||
onProgress: send,
|
onProgress: send,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +91,8 @@ export async function POST(request: Request) {
|
|||||||
const result = await runAgentChat({
|
const result = await runAgentChat({
|
||||||
messages: parsed.data.messages,
|
messages: parsed.data.messages,
|
||||||
request,
|
request,
|
||||||
|
systemPrompt,
|
||||||
|
model: modelResolution.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
return apiJson({
|
return apiJson({
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { apiError, apiJson } from "@/lib/api-handler";
|
||||||
|
import { resolveApiAuth } from "@/lib/api-auth";
|
||||||
|
import { getAssistantPreferences } from "@/lib/assistant-preference";
|
||||||
|
import { isValidAssistantModelRoute, listLlmModels, resolveAssistantModel } from "@/lib/llm/models";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function noStore(response: Response): Response {
|
||||||
|
response.headers.set("Cache-Control", "no-store");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await resolveApiAuth(request);
|
||||||
|
if (!auth?.userId) {
|
||||||
|
return noStore(apiError("Unauthorized", 401));
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = await getAssistantPreferences(auth.userId);
|
||||||
|
if (!assistant.enabled) {
|
||||||
|
return noStore(apiError("Assistant not enabled", 403));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedRoute = new URL(request.url).searchParams.get("route");
|
||||||
|
if (requestedRoute !== null && !isValidAssistantModelRoute(requestedRoute)) {
|
||||||
|
return noStore(apiError("Invalid assistant model route", 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelRoute = requestedRoute ?? assistant.modelRoute;
|
||||||
|
const modelList = await listLlmModels({ route: modelRoute });
|
||||||
|
const resolved = resolveAssistantModel({
|
||||||
|
requestedModel: null,
|
||||||
|
savedModel: requestedRoute === null ? assistant.model : null,
|
||||||
|
fallbackModel: modelList.fallbackModel,
|
||||||
|
models: modelList.models,
|
||||||
|
});
|
||||||
|
|
||||||
|
return noStore(
|
||||||
|
apiJson({
|
||||||
|
models: modelList.models,
|
||||||
|
selectedModel: resolved.model,
|
||||||
|
fallbackModel: modelList.fallbackModel,
|
||||||
|
route: modelList.route,
|
||||||
|
degraded: modelList.degraded,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { apiError, apiJson } from "@/lib/api-handler";
|
||||||
|
import { resolveApiAuth } from "@/lib/api-auth";
|
||||||
|
import { getAssistantPreferences } from "@/lib/assistant-preference";
|
||||||
|
import { transcribeAudioFile } from "@/lib/llm/transcribe";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
const MAX_AUDIO_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await resolveApiAuth(request);
|
||||||
|
if (!auth?.userId) {
|
||||||
|
return apiError("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = await getAssistantPreferences(auth.userId);
|
||||||
|
if (!assistant.enabled) {
|
||||||
|
return apiError("Assistant not enabled", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
let formData: FormData;
|
||||||
|
try {
|
||||||
|
formData = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return apiError("Invalid multipart body", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return apiError("No audio file in request", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.startsWith("audio/") && file.type !== "video/webm") {
|
||||||
|
return apiError("Only audio recordings are allowed", 415);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_AUDIO_BYTES) {
|
||||||
|
return apiError("Recording exceeds 25 MB limit", 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = file.name.trim() || "recording.wav";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await transcribeAudioFile(file, filename);
|
||||||
|
return apiJson({ text });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Transcription failed";
|
||||||
|
return apiError(message, 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,9 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const scope = url.searchParams.get("scope") === "notes" ? "notes" : "garden";
|
const scopeParam = url.searchParams.get("scope");
|
||||||
|
const scope =
|
||||||
|
scopeParam === "notes" ? "notes" : scopeParam === "assistant" ? "assistant" : "garden";
|
||||||
|
|
||||||
if (!file.type.startsWith("image/")) {
|
if (!file.type.startsWith("image/")) {
|
||||||
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
|
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
||||||
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
|
import {
|
||||||
|
getDefaultEventReminderOffsets,
|
||||||
|
listCalendars,
|
||||||
|
listEvents,
|
||||||
|
} from "@/modules/calendar/server/queries";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import type { CalView } from "@/modules/_core/themes";
|
import type { CalView } from "@/modules/_core/themes";
|
||||||
|
|
||||||
@@ -10,10 +14,11 @@ export default async function CalendarPage() {
|
|||||||
const to = new Date(now);
|
const to = new Date(now);
|
||||||
to.setMonth(to.getMonth() + 10);
|
to.setMonth(to.getMonth() + 10);
|
||||||
|
|
||||||
const [{ user }, calendars, events] = await Promise.all([
|
const [{ user }, calendars, events, defaultReminderOffsets] = await Promise.all([
|
||||||
getCurrentSession(),
|
getCurrentSession(),
|
||||||
listCalendars(),
|
listCalendars(),
|
||||||
listEvents({ from, to, calendarIds: "all" }),
|
listEvents({ from, to, calendarIds: "all" }),
|
||||||
|
getDefaultEventReminderOffsets(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,6 +26,7 @@ export default async function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
events={events}
|
events={events}
|
||||||
defaultView={user.themeCalView as CalView}
|
defaultView={user.themeCalView as CalView}
|
||||||
|
defaultReminderOffsets={defaultReminderOffsets}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { parseDashboardLayout, widgetContentKey } from "@/lib/dashboard";
|
import { parseDashboardLayout, widgetContentIndexKey } from "@/lib/dashboard";
|
||||||
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
|
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
|
||||||
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
|
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
|
||||||
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
||||||
@@ -58,10 +58,10 @@ export default async function DashboardPage({
|
|||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
const ctx = { userId: user.id, householdId: household.id };
|
const ctx = { userId: user.id, householdId: household.id };
|
||||||
const widgetContents = Object.fromEntries(
|
const widgetContents = Object.fromEntries(
|
||||||
layout.widgets.map((placement) => [
|
layout.widgets.map((placement, index) => [
|
||||||
widgetContentKey(placement),
|
widgetContentIndexKey(index),
|
||||||
<DashboardWidgetContent
|
<DashboardWidgetContent
|
||||||
key={widgetContentKey(placement)}
|
key={widgetContentIndexKey(index)}
|
||||||
placement={placement}
|
placement={placement}
|
||||||
ctx={ctx}
|
ctx={ctx}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
+13
-2
@@ -725,9 +725,19 @@
|
|||||||
|
|
||||||
.scroll-area {
|
.scroll-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 20px 24px 60px;
|
padding: 20px 24px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bare routes (/s/*, /login, signed-out) — same scrollport as .scroll-area
|
||||||
|
without app chrome padding. Needed because html/body are overflow:hidden. */
|
||||||
|
.bare-scroll {
|
||||||
|
height: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
:where(html[data-nav="bottom"]) .scroll-area,
|
:where(html[data-nav="bottom"]) .scroll-area,
|
||||||
:where(html[data-nav="fab"]) .scroll-area {
|
:where(html[data-nav="fab"]) .scroll-area {
|
||||||
padding: 12px 14px 74px;
|
padding: 12px 14px 74px;
|
||||||
@@ -1617,7 +1627,8 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Momentum scrolling + prevent page bounce fighting in-app scroll */
|
/* Momentum scrolling + prevent page bounce fighting in-app scroll */
|
||||||
.scroll-area {
|
.scroll-area,
|
||||||
|
.bare-scroll {
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google";
|
import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -18,6 +19,7 @@ import { InstallPrompt } from "@/components/install-prompt";
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { AppToaster } from "@/components/app-toaster";
|
import { AppToaster } from "@/components/app-toaster";
|
||||||
import { AssistantBubble } from "@/modules/agent/components/assistant-bubble";
|
import { AssistantBubble } from "@/modules/agent/components/assistant-bubble";
|
||||||
|
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
|
||||||
import { isLlmConfigured } from "@/lib/llm";
|
import { isLlmConfigured } from "@/lib/llm";
|
||||||
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
|
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
|
||||||
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
|
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
|
||||||
@@ -70,9 +72,13 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
// Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark.
|
// Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark.
|
||||||
// Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored.
|
// Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored.
|
||||||
|
// Public share pages always use ink so guests get a neutral, consistent look.
|
||||||
const prePaintScript = `(function(){
|
const prePaintScript = `(function(){
|
||||||
try {
|
try {
|
||||||
var palette = localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay';
|
var isShare = location.pathname.indexOf('/s/') === 0;
|
||||||
|
var palette = isShare
|
||||||
|
? 'ink'
|
||||||
|
: (localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay');
|
||||||
var mode = localStorage.getItem('themeMode') || 'system';
|
var mode = localStorage.getItem('themeMode') || 'system';
|
||||||
var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans';
|
var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans';
|
||||||
var density = localStorage.getItem('themeDensity') || 'regular';
|
var density = localStorage.getItem('themeDensity') || 'regular';
|
||||||
@@ -102,6 +108,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
let userDashboards: DashboardMeta[] = [];
|
let userDashboards: DashboardMeta[] = [];
|
||||||
let signedIn = false;
|
let signedIn = false;
|
||||||
let assistantEnabled = false;
|
let assistantEnabled = false;
|
||||||
|
let assistantName = DEFAULT_ASSISTANT_NAME;
|
||||||
|
let assistantModel: string | null = null;
|
||||||
|
|
||||||
|
const headersList = await headers();
|
||||||
|
const pathname = headersList.get("x-pathname") ?? "";
|
||||||
|
const isSharePage = pathname === "/s" || pathname.startsWith("/s/");
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
@@ -114,6 +126,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
themeDensity: users.themeDensity,
|
themeDensity: users.themeDensity,
|
||||||
themeNavStyle: users.themeNavStyle,
|
themeNavStyle: users.themeNavStyle,
|
||||||
assistantEnabled: users.assistantEnabled,
|
assistantEnabled: users.assistantEnabled,
|
||||||
|
assistantName: users.assistantName,
|
||||||
|
assistantModel: users.assistantModel,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, session.user.id))
|
.where(eq(users.id, session.user.id))
|
||||||
@@ -125,6 +139,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
density = row.themeDensity as Density;
|
density = row.themeDensity as Density;
|
||||||
navStyle = row.themeNavStyle as NavStyle;
|
navStyle = row.themeNavStyle as NavStyle;
|
||||||
assistantEnabled = row.assistantEnabled;
|
assistantEnabled = row.assistantEnabled;
|
||||||
|
assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME;
|
||||||
|
assistantModel = row.assistantModel?.trim() || null;
|
||||||
}
|
}
|
||||||
userDashboards = await db
|
userDashboards = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -139,6 +155,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSharePage) {
|
||||||
|
palette = "ink";
|
||||||
|
}
|
||||||
|
|
||||||
// Server-side initial dark guess: only for `dark` mode (system mode is corrected
|
// Server-side initial dark guess: only for `dark` mode (system mode is corrected
|
||||||
// before paint by the inline script). Avoids a flash on signed-in users.
|
// before paint by the inline script). Avoids a flash on signed-in users.
|
||||||
const isDark = mode === "dark";
|
const isDark = mode === "dark";
|
||||||
@@ -178,7 +198,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
{signedIn && assistantEnabled && session?.user?.id ? (
|
{signedIn && assistantEnabled && session?.user?.id ? (
|
||||||
<AssistantBubble configured={isLlmConfigured()} userId={session.user.id} />
|
<AssistantBubble
|
||||||
|
configured={isLlmConfigured()}
|
||||||
|
userId={session.user.id}
|
||||||
|
assistantName={assistantName}
|
||||||
|
assistantModel={assistantModel}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AppToaster position="bottom-right" />
|
<AppToaster position="bottom-right" />
|
||||||
</QuickAddProvider>
|
</QuickAddProvider>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { ListDetail } from "@/modules/lists/components/list-detail";
|
import { ListDetail } from "@/modules/lists/components/list-detail";
|
||||||
import { getList } from "@/modules/lists/server/queries";
|
import { getList } from "@/modules/lists/server/queries";
|
||||||
|
|
||||||
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const list = await getList(id).catch(() => null);
|
const [{ user }, list] = await Promise.all([getCurrentSession(), getList(id).catch(() => null)]);
|
||||||
if (!list) notFound();
|
if (!list) notFound();
|
||||||
|
|
||||||
return <ListDetail initialList={list} />;
|
return <ListDetail initialList={list} currentUserId={user.id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { NoteEditor } from "@/modules/notes/components/note-editor";
|
import { NoteEditor } from "@/modules/notes/components/note-editor";
|
||||||
import { getNote } from "@/modules/notes/server/queries";
|
import { getNote } from "@/modules/notes/server/queries";
|
||||||
|
|
||||||
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const note = await getNote(id).catch(() => null);
|
const [{ user }, note] = await Promise.all([getCurrentSession(), getNote(id).catch(() => null)]);
|
||||||
if (!note) notFound();
|
if (!note) notFound();
|
||||||
|
|
||||||
return <NoteEditor note={note} />;
|
return <NoteEditor note={note} currentUserId={user.id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,98 @@
|
|||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
DEFAULT_ASSISTANT_NAME,
|
||||||
|
MAX_ASSISTANT_NAME_LENGTH,
|
||||||
|
MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH,
|
||||||
|
} from "@/lib/assistant-config";
|
||||||
|
import {
|
||||||
|
isValidAssistantModelRoute,
|
||||||
|
isValidLlmModelId,
|
||||||
|
listLlmModels,
|
||||||
|
type AssistantModelRoute,
|
||||||
|
} from "@/lib/llm/models";
|
||||||
import { users } from "@/modules/_core/schema";
|
import { users } from "@/modules/_core/schema";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { getAssistantPreferences } from "@/lib/assistant-preference";
|
||||||
|
|
||||||
|
const assistantNameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Name is required")
|
||||||
|
.max(MAX_ASSISTANT_NAME_LENGTH);
|
||||||
|
|
||||||
|
const assistantSystemPromptSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Prompt cannot be empty")
|
||||||
|
.max(MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH);
|
||||||
|
|
||||||
|
function revalidateAssistantSurfaces() {
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
export async function setAssistantEnabled(enabled: boolean): Promise<void> {
|
export async function setAssistantEnabled(enabled: boolean): Promise<void> {
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id));
|
await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id));
|
||||||
revalidatePath("/settings");
|
revalidateAssistantSurfaces();
|
||||||
revalidatePath("/", "layout");
|
}
|
||||||
|
|
||||||
|
export async function setAssistantName(name: string): Promise<void> {
|
||||||
|
const parsed = assistantNameSchema.parse(name);
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db.update(users).set({ assistantName: parsed }).where(eq(users.id, user.id));
|
||||||
|
revalidateAssistantSurfaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAssistantSystemPrompt(prompt: string | null): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const normalized = prompt === null ? null : assistantSystemPromptSchema.parse(prompt);
|
||||||
|
|
||||||
|
await db.update(users).set({ assistantSystemPrompt: normalized }).where(eq(users.id, user.id));
|
||||||
|
revalidateAssistantSurfaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAssistantModelRoute(route: AssistantModelRoute): Promise<void> {
|
||||||
|
if (!isValidAssistantModelRoute(route)) {
|
||||||
|
throw new Error("Invalid assistant model route");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ assistantModelRoute: route, assistantModel: null })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
revalidateAssistantSurfaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAssistantModel(model: string | null): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const normalized = model?.trim() || null;
|
||||||
|
|
||||||
|
if (normalized !== null && !isValidLlmModelId(normalized)) {
|
||||||
|
throw new Error("Invalid assistant model");
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = await getAssistantPreferences(user.id);
|
||||||
|
const available = await listLlmModels({ route: assistant.modelRoute });
|
||||||
|
const requested = normalized === available.fallbackModel ? null : normalized;
|
||||||
|
|
||||||
|
if (requested !== null && !available.models.some((option) => option.id === requested)) {
|
||||||
|
throw new Error("Invalid assistant model");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users).set({ assistantModel: requested }).where(eq(users.id, user.id));
|
||||||
|
revalidateAssistantSurfaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAssistantName(): Promise<void> {
|
||||||
|
await setAssistantName(DEFAULT_ASSISTANT_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAssistantSystemPrompt(): Promise<void> {
|
||||||
|
await setAssistantSystemPrompt(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
|||||||
import { revokeShareLinkAction } from "./actions";
|
import { revokeShareLinkAction } from "./actions";
|
||||||
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
|
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
|
||||||
import { ApiTokenSettings } from "@/components/api-token-settings";
|
import { ApiTokenSettings } from "@/components/api-token-settings";
|
||||||
import { AssistantOptIn } from "@/components/assistant-opt-in";
|
import { AssistantSettings } from "@/components/assistant-settings";
|
||||||
|
import { AGENT_SYSTEM_PROMPT } from "@/modules/agent/tools";
|
||||||
|
import { DefaultEventRemindersSetting } from "@/components/default-event-reminders-setting";
|
||||||
import { listCalendars } from "@/modules/calendar/server/queries";
|
import { listCalendars } from "@/modules/calendar/server/queries";
|
||||||
import { listLists } from "@/modules/lists/server/queries";
|
import { listLists } from "@/modules/lists/server/queries";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -61,7 +63,12 @@ export default async function SettingsPage({
|
|||||||
{section === "household" && <HouseholdSection household={household} user={user} />}
|
{section === "household" && <HouseholdSection household={household} user={user} />}
|
||||||
{section === "sharing" && <SharingSection />}
|
{section === "sharing" && <SharingSection />}
|
||||||
{section === "notifications" && (
|
{section === "notifications" && (
|
||||||
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
|
<NotificationsSection
|
||||||
|
user={user}
|
||||||
|
defaultEventReminderOffsets={user.defaultEventReminderOffsets ?? [30]}
|
||||||
|
vapidKey={vapidKey}
|
||||||
|
ntfyConfigured={ntfyConfigured}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{section === "calendars" && <CalendarsAndListsSection />}
|
{section === "calendars" && <CalendarsAndListsSection />}
|
||||||
{section === "appearance" && <AppearanceSection user={user} />}
|
{section === "appearance" && <AppearanceSection user={user} />}
|
||||||
@@ -185,10 +192,12 @@ async function SharingSection() {
|
|||||||
|
|
||||||
function NotificationsSection({
|
function NotificationsSection({
|
||||||
user,
|
user,
|
||||||
|
defaultEventReminderOffsets,
|
||||||
vapidKey,
|
vapidKey,
|
||||||
ntfyConfigured,
|
ntfyConfigured,
|
||||||
}: {
|
}: {
|
||||||
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
||||||
|
defaultEventReminderOffsets: number[];
|
||||||
vapidKey: string;
|
vapidKey: string;
|
||||||
ntfyConfigured: boolean;
|
ntfyConfigured: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -216,6 +225,18 @@ function NotificationsSection({
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Default event reminders</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DefaultEventRemindersSetting
|
||||||
|
key={defaultEventReminderOffsets.join(",")}
|
||||||
|
initialOffsets={defaultEventReminderOffsets}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,6 +323,9 @@ function AppearanceSection({
|
|||||||
themeCalView: string;
|
themeCalView: string;
|
||||||
themeNavStyle: string;
|
themeNavStyle: string;
|
||||||
assistantEnabled: boolean;
|
assistantEnabled: boolean;
|
||||||
|
assistantName: string;
|
||||||
|
assistantModelRoute: string | null;
|
||||||
|
assistantSystemPrompt: string | null;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -335,7 +359,13 @@ function AppearanceSection({
|
|||||||
<MessageCircle className="size-4 text-[var(--ink-mute)]" />
|
<MessageCircle className="size-4 text-[var(--ink-mute)]" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<AssistantOptIn enabled={user.assistantEnabled} />
|
<AssistantSettings
|
||||||
|
enabled={user.assistantEnabled}
|
||||||
|
name={user.assistantName}
|
||||||
|
modelRoute={user.assistantModelRoute}
|
||||||
|
systemPrompt={user.assistantSystemPrompt}
|
||||||
|
defaultSystemPrompt={AGENT_SYSTEM_PROMPT}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
|
||||||
|
import { users } from "@/modules/_core/schema";
|
||||||
|
|
||||||
|
const offsetsSchema = z.array(z.number().int().min(0)).max(20);
|
||||||
|
|
||||||
|
export async function setDefaultEventReminderOffsets(offsets: number[]) {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const parsed = normalizeReminderOffsets(offsetsSchema.parse(offsets));
|
||||||
|
|
||||||
|
await db.update(users).set({ defaultEventReminderOffsets: parsed }).where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
revalidatePath("/settings");
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ export async function AppShell({ signedIn, navStyle, children }: Props) {
|
|||||||
|
|
||||||
if (bare || !signedIn) {
|
if (bare || !signedIn) {
|
||||||
// No shell — share viewer and signed-out pages render bare.
|
// No shell — share viewer and signed-out pages render bare.
|
||||||
return <>{children}</>;
|
// html/body are overflow:hidden for the app grid; bare pages need their own scroller.
|
||||||
|
return <div className="bare-scroll">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useTopVariant = navStyle === "top-nav";
|
const useTopVariant = navStyle === "top-nav";
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTransition } from "react";
|
|
||||||
import { setAssistantEnabled } from "@/app/settings/assistant-actions";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
|
|
||||||
export function AssistantOptIn({ enabled }: { enabled: boolean }) {
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
function toggle(next: boolean) {
|
|
||||||
startTransition(async () => {
|
|
||||||
await setAssistantEnabled(next);
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium">AI assistant</div>
|
|
||||||
<p className="muted text-[12px] mt-0.5">
|
|
||||||
Off by default. Turn on to show a chat bubble in the bottom-right corner.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={enabled}
|
|
||||||
disabled={isPending}
|
|
||||||
onCheckedChange={toggle}
|
|
||||||
aria-label="AI assistant"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import {
|
||||||
|
resetAssistantName,
|
||||||
|
resetAssistantSystemPrompt,
|
||||||
|
setAssistantEnabled,
|
||||||
|
setAssistantName,
|
||||||
|
setAssistantModelRoute,
|
||||||
|
setAssistantSystemPrompt,
|
||||||
|
} from "@/app/settings/assistant-actions";
|
||||||
|
import { DEFAULT_ASSISTANT_NAME, MAX_ASSISTANT_NAME_LENGTH } from "@/lib/assistant-config";
|
||||||
|
import type { AssistantModelRoute } from "@/lib/llm/models";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
|
modelRoute: string | null;
|
||||||
|
systemPrompt: string | null;
|
||||||
|
defaultSystemPrompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeAssistantModelRoute(value: string | null): AssistantModelRoute {
|
||||||
|
return value === "uncensored" ? "uncensored" : "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssistantSettings({
|
||||||
|
enabled,
|
||||||
|
name,
|
||||||
|
modelRoute,
|
||||||
|
systemPrompt,
|
||||||
|
defaultSystemPrompt,
|
||||||
|
}: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const effectivePrompt = systemPrompt ?? defaultSystemPrompt;
|
||||||
|
const [selectedRoute, setSelectedRoute] = useState<AssistantModelRoute>(() =>
|
||||||
|
normalizeAssistantModelRoute(modelRoute),
|
||||||
|
);
|
||||||
|
const [savedName, setSavedName] = useState(name);
|
||||||
|
const [draftName, setDraftName] = useState(name);
|
||||||
|
const [savedPrompt, setSavedPrompt] = useState(systemPrompt);
|
||||||
|
const [draftPrompt, setDraftPrompt] = useState(effectivePrompt);
|
||||||
|
|
||||||
|
const nameDirty = draftName.trim() !== savedName;
|
||||||
|
const promptDirty =
|
||||||
|
savedPrompt === null
|
||||||
|
? draftPrompt.trim() !== defaultSystemPrompt.trim()
|
||||||
|
: draftPrompt.trim() !== savedPrompt.trim();
|
||||||
|
const usingDefaultPrompt = savedPrompt === null;
|
||||||
|
|
||||||
|
function toggle(next: boolean) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await setAssistantEnabled(next);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeModelRoute(nextRoute: string) {
|
||||||
|
const route = normalizeAssistantModelRoute(nextRoute);
|
||||||
|
setSelectedRoute(route);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await setAssistantModelRoute(route);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveName() {
|
||||||
|
const next = draftName.trim();
|
||||||
|
if (!next) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await setAssistantName(next);
|
||||||
|
setSavedName(next);
|
||||||
|
setDraftName(next);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrompt() {
|
||||||
|
const next = draftPrompt.trim();
|
||||||
|
if (!next) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const isDefault = next === defaultSystemPrompt.trim();
|
||||||
|
await setAssistantSystemPrompt(isDefault ? null : next);
|
||||||
|
setSavedPrompt(isDefault ? null : next);
|
||||||
|
setDraftPrompt(next);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetName() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await resetAssistantName();
|
||||||
|
setSavedName(DEFAULT_ASSISTANT_NAME);
|
||||||
|
setDraftName(DEFAULT_ASSISTANT_NAME);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetPrompt() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await resetAssistantSystemPrompt();
|
||||||
|
setSavedPrompt(null);
|
||||||
|
setDraftPrompt(defaultSystemPrompt);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<label className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">AI assistant</div>
|
||||||
|
<p className="muted text-[12px] mt-0.5">
|
||||||
|
Off by default. Turn on to show a chat bubble in the bottom-right corner.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
disabled={isPending}
|
||||||
|
onCheckedChange={toggle}
|
||||||
|
aria-label="AI assistant"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<label htmlFor="assistant-model-route" className="text-sm font-medium">
|
||||||
|
Model route
|
||||||
|
</label>
|
||||||
|
<p className="muted text-[12px] mt-0.5">
|
||||||
|
Choose the default router family for your assistant. Individual models can still be
|
||||||
|
picked from the chat panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="assistant-model-route"
|
||||||
|
aria-label="Assistant model route"
|
||||||
|
value={selectedRoute}
|
||||||
|
disabled={isPending}
|
||||||
|
onChange={(event) => changeModelRoute(event.target.value)}
|
||||||
|
className="input h-10"
|
||||||
|
>
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="uncensored">Uncensored</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<label htmlFor="assistant-name" className="text-sm font-medium">
|
||||||
|
Assistant name
|
||||||
|
</label>
|
||||||
|
<p className="muted text-[12px] mt-0.5">Shown in the chat bubble and message labels.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isPending || savedName === DEFAULT_ASSISTANT_NAME}
|
||||||
|
onClick={handleResetName}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="assistant-name"
|
||||||
|
value={draftName}
|
||||||
|
maxLength={MAX_ASSISTANT_NAME_LENGTH}
|
||||||
|
disabled={isPending}
|
||||||
|
onChange={(event) => setDraftName(event.target.value)}
|
||||||
|
aria-label="Assistant name"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!nameDirty || isPending || !draftName.trim()}
|
||||||
|
onClick={saveName}
|
||||||
|
>
|
||||||
|
{isPending ? "Saving…" : "Save name"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<label htmlFor="assistant-system-prompt" className="text-sm font-medium">
|
||||||
|
System prompt
|
||||||
|
</label>
|
||||||
|
<p className="muted text-[12px] mt-0.5">
|
||||||
|
Instructions sent to the model before each conversation. Customize tone, priorities,
|
||||||
|
or household context.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isPending || usingDefaultPrompt}
|
||||||
|
onClick={handleResetPrompt}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="assistant-system-prompt"
|
||||||
|
value={draftPrompt}
|
||||||
|
rows={10}
|
||||||
|
disabled={isPending}
|
||||||
|
onChange={(event) => setDraftPrompt(event.target.value)}
|
||||||
|
aria-label="Assistant system prompt"
|
||||||
|
className="input min-h-[180px] resize-y font-mono text-[12px] leading-relaxed"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!promptDirty || isPending || !draftPrompt.trim()}
|
||||||
|
onClick={savePrompt}
|
||||||
|
>
|
||||||
|
{isPending ? "Saving…" : "Save prompt"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MessageSquare, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { addComment, deleteComment, listComments, type CommentDto } from "@/modules/_core/comments";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
currentUserId: string;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EntityComments({ entityType, entityId, currentUserId, compact = false }: Props) {
|
||||||
|
const [open, setOpen] = useState(!compact);
|
||||||
|
const [comments, setComments] = useState<CommentDto[]>([]);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
listComments(entityType, entityId)
|
||||||
|
.then(setComments)
|
||||||
|
.catch(() => setComments([]));
|
||||||
|
}, [entityType, entityId, open]);
|
||||||
|
|
||||||
|
function submitComment() {
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const created = await addComment({ entityType, entityId, body });
|
||||||
|
setComments((current) => [...current, created]);
|
||||||
|
setDraft("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeComment(id: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteComment({ id });
|
||||||
|
setComments((current) => current.filter((comment) => comment.id !== id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={compact ? "mt-1" : "mt-3 border-t border-[var(--hair)] pt-3"}>
|
||||||
|
{compact ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 text-[12px] text-[var(--ink-mute)] hover:text-[var(--ink)]"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
{open ? "Hide comments" : "Comments"}
|
||||||
|
{comments.length > 0 ? ` (${comments.length})` : ""}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="eyebrow mb-2">Comments</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-[var(--ink-mute)]">No comments yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<li
|
||||||
|
key={comment.id}
|
||||||
|
className="rounded-md border border-[var(--hair)] px-2.5 py-2 text-[13px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] text-[var(--ink-mute)]">
|
||||||
|
{comment.authorName ?? "Someone"} · {formatCommentTime(comment.createdAt)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 whitespace-pre-wrap break-words">{comment.body}</p>
|
||||||
|
</div>
|
||||||
|
{comment.authorId === currentUserId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Delete comment"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => removeComment(comment.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder="Add a comment…"
|
||||||
|
disabled={isPending}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitComment();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!draft.trim() || isPending}
|
||||||
|
onClick={submitComment}
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommentTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { GridLayout } from "react-grid-layout";
|
|||||||
import type { Layout } from "react-grid-layout";
|
import type { Layout } from "react-grid-layout";
|
||||||
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
|
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
|
||||||
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
|
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
|
||||||
import { computePresetLayoutFromMetas, widgetContentKey } from "@/lib/dashboard";
|
import { computePresetLayoutFromMetas, widgetContentIndexKey } from "@/lib/dashboard";
|
||||||
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
|
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
|
||||||
import {
|
import {
|
||||||
saveDashboardLayout,
|
saveDashboardLayout,
|
||||||
@@ -113,10 +113,18 @@ export function DashboardEditor({
|
|||||||
function addWidget(widgetId: string, config: unknown) {
|
function addWidget(widgetId: string, config: unknown) {
|
||||||
const meta = widgetMetas.find((m) => m.id === widgetId);
|
const meta = widgetMetas.find((m) => m.id === widgetId);
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
|
const resolvedConfig = resolveWidgetConfig(meta, config);
|
||||||
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
|
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
|
||||||
const next = [
|
const next = [
|
||||||
...placements,
|
...placements,
|
||||||
{ widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h },
|
{
|
||||||
|
widgetId,
|
||||||
|
config: resolvedConfig,
|
||||||
|
x: 0,
|
||||||
|
y: maxY,
|
||||||
|
w: meta.defaultSize.w,
|
||||||
|
h: meta.defaultSize.h,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
setPlacements(next);
|
setPlacements(next);
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
@@ -125,7 +133,9 @@ export function DashboardEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateConfig(index: number, config: unknown) {
|
function updateConfig(index: number, config: unknown) {
|
||||||
const next = placements.map((p, i) => (i === index ? { ...p, config } : p));
|
const meta = widgetMetas.find((m) => m.id === placements[index]?.widgetId);
|
||||||
|
const resolvedConfig = meta ? resolveWidgetConfig(meta, config) : config;
|
||||||
|
const next = placements.map((p, i) => (i === index ? { ...p, config: resolvedConfig } : p));
|
||||||
setPlacements(next);
|
setPlacements(next);
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
setConfiguringIndex(null);
|
setConfiguringIndex(null);
|
||||||
@@ -237,7 +247,7 @@ export function DashboardEditor({
|
|||||||
>
|
>
|
||||||
{placements.map((placement, i) => {
|
{placements.map((placement, i) => {
|
||||||
const meta = widgetMetas.find((m) => m.id === placement.widgetId);
|
const meta = widgetMetas.find((m) => m.id === placement.widgetId);
|
||||||
const content = widgetContents[widgetContentKey(placement)];
|
const content = widgetContents[widgetContentIndexKey(i)];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={placementKey(placement, i)}
|
key={placementKey(placement, i)}
|
||||||
@@ -308,3 +318,13 @@ export function DashboardEditor({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveWidgetConfig(meta: SerializedWidgetMeta, config: unknown): unknown {
|
||||||
|
if (config != null && typeof config === "object" && !Array.isArray(config)) {
|
||||||
|
return {
|
||||||
|
...(meta.defaultConfig as Record<string, unknown>),
|
||||||
|
...(config as Record<string, unknown>),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return meta.defaultConfig;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { ReminderPicker } from "@/components/reminder-picker";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { setDefaultEventReminderOffsets } from "@/app/settings/reminder-actions";
|
||||||
|
|
||||||
|
export function DefaultEventRemindersSetting({ initialOffsets }: { initialOffsets: number[] }) {
|
||||||
|
const [saved, setSaved] = useState(initialOffsets);
|
||||||
|
const [offsets, setOffsets] = useState(initialOffsets);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
offsets.length !== saved.length || offsets.some((value, index) => value !== saved[index]);
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await setDefaultEventReminderOffsets(offsets);
|
||||||
|
setSaved(offsets);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="muted text-[13px]">
|
||||||
|
Applied when you create a new calendar event. You can still customize reminders per event.
|
||||||
|
</p>
|
||||||
|
<ReminderPicker offsets={offsets} onChange={setOffsets} disabled={isPending} />
|
||||||
|
<Button type="button" size="sm" disabled={!dirty || isPending} onClick={handleSave}>
|
||||||
|
{isPending ? "Saving…" : "Save defaults"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
|
import { ReminderPicker } from "@/components/reminder-picker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -22,7 +23,11 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { createEvent } from "@/modules/calendar/server/actions";
|
import { createEvent } from "@/modules/calendar/server/actions";
|
||||||
import { richTextToPlainText } from "@/components/rich-text";
|
import { richTextToPlainText } from "@/components/rich-text";
|
||||||
import { listCalendars, type CalendarDto } from "@/modules/calendar/server/queries";
|
import {
|
||||||
|
getDefaultEventReminderOffsets,
|
||||||
|
listCalendars,
|
||||||
|
type CalendarDto,
|
||||||
|
} from "@/modules/calendar/server/queries";
|
||||||
|
|
||||||
const RichTextEditor = dynamic(
|
const RichTextEditor = dynamic(
|
||||||
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
|
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
|
||||||
@@ -59,7 +64,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000)));
|
const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000)));
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [remind, setRemind] = useState(true);
|
const [reminderOffsets, setReminderOffsets] = useState<number[]>([30]);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,6 +72,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
setCalendars(rows);
|
setCalendars(rows);
|
||||||
setCalendarId(rows[0]?.id ?? "");
|
setCalendarId(rows[0]?.id ?? "");
|
||||||
});
|
});
|
||||||
|
getDefaultEventReminderOffsets().then(setReminderOffsets);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calendarItems = useMemo(
|
const calendarItems = useMemo(
|
||||||
@@ -86,7 +92,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
allDay: false,
|
allDay: false,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
notes: richTextToPlainText(notes) ? notes : null,
|
notes: richTextToPlainText(notes) ? notes : null,
|
||||||
remindMinutesBefore: remind ? 30 : null,
|
reminderOffsets,
|
||||||
});
|
});
|
||||||
onDone();
|
onDone();
|
||||||
});
|
});
|
||||||
@@ -171,15 +177,11 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
placeholder="Add details…"
|
placeholder="Add details…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<ReminderPicker
|
||||||
<input
|
offsets={reminderOffsets}
|
||||||
type="checkbox"
|
onChange={setReminderOffsets}
|
||||||
className="size-4 cursor-pointer"
|
disabled={isPending}
|
||||||
checked={remind}
|
/>
|
||||||
onChange={(e) => setRemind(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Remind me 30 min before
|
|
||||||
</label>
|
|
||||||
</form>
|
</form>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}>
|
<Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
REMINDER_PRESETS,
|
||||||
|
customOffsetToMinutes,
|
||||||
|
formatReminderOffset,
|
||||||
|
normalizeReminderOffsets,
|
||||||
|
type ReminderOffsetUnit,
|
||||||
|
} from "@/lib/reminder-offsets";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
offsets: number[];
|
||||||
|
onChange: (offsets: number[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReminderPicker({ offsets, onChange, disabled }: Props) {
|
||||||
|
const [customValue, setCustomValue] = useState("1");
|
||||||
|
const [customUnit, setCustomUnit] = useState<ReminderOffsetUnit>("hours");
|
||||||
|
const normalized = useMemo(() => normalizeReminderOffsets(offsets), [offsets]);
|
||||||
|
|
||||||
|
function addOffset(minutes: number) {
|
||||||
|
onChange(normalizeReminderOffsets([...normalized, minutes]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOffset(minutes: number) {
|
||||||
|
onChange(normalized.filter((o) => o !== minutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustom() {
|
||||||
|
const parsed = Number.parseInt(customValue, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) return;
|
||||||
|
addOffset(customOffsetToMinutes(parsed, customUnit));
|
||||||
|
}
|
||||||
|
|
||||||
|
const availablePresets = REMINDER_PRESETS.filter((p) => !normalized.includes(p.offsetMinutes));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Reminders</Label>
|
||||||
|
{normalized.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-[var(--ink-mute)]">No reminders set.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{normalized.map((offset) => (
|
||||||
|
<li
|
||||||
|
key={offset}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hair)] px-2.5 py-1.5 text-[13px]"
|
||||||
|
>
|
||||||
|
<span>{formatReminderOffset(offset)}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove ${formatReminderOffset(offset)}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => removeOffset(offset)}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{availablePresets.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{availablePresets.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.offsetMinutes}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => addOffset(preset.offsetMinutes)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[80px_120px_auto] sm:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-custom-value">Custom</Label>
|
||||||
|
<Input
|
||||||
|
id="reminder-custom-value"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={customValue}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setCustomValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-custom-unit">Unit</Label>
|
||||||
|
<Select
|
||||||
|
value={customUnit}
|
||||||
|
onValueChange={(value) => setCustomUnit(value as ReminderOffsetUnit)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="reminder-custom-unit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="minutes">Minutes</SelectItem>
|
||||||
|
<SelectItem value="hours">Hours</SelectItem>
|
||||||
|
<SelectItem value="days">Days</SelectItem>
|
||||||
|
<SelectItem value="weeks">Weeks</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" disabled={disabled} onClick={addCustom}>
|
||||||
|
Add custom
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,9 @@
|
|||||||
margin: 0.8em 0;
|
margin: 0.8em 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
padding: 0.75rem 0.9rem;
|
padding: 0.75rem 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +94,12 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin: 0.8em 0;
|
margin: 0.8em 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
/* overflow-x alone computes overflow-y to auto, which traps vertical
|
||||||
|
touch scrolling on iOS when a wide table fills the viewport. */
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Public origin for absolute links. AUTH_URL wins — Docker build sets NEXT_PUBLIC to localhost. */
|
||||||
|
export function getAppPublicUrl(): string {
|
||||||
|
const raw =
|
||||||
|
process.env.AUTH_URL?.trim() ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL?.trim() ||
|
||||||
|
"http://localhost:3000";
|
||||||
|
return raw.replace(/\/$/, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { AGENT_SYSTEM_PROMPT } from "@/modules/agent/tools";
|
||||||
|
|
||||||
|
export const DEFAULT_ASSISTANT_NAME = "Assistant";
|
||||||
|
export const MAX_ASSISTANT_NAME_LENGTH = 40;
|
||||||
|
export const MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH = 8000;
|
||||||
|
|
||||||
|
export function resolveAssistantSystemPrompt(customPrompt: string | null | undefined): string {
|
||||||
|
const trimmed = customPrompt?.trim();
|
||||||
|
return trimmed ? trimmed : AGENT_SYSTEM_PROMPT;
|
||||||
|
}
|
||||||
@@ -1,13 +1,50 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
|
||||||
|
import { isValidAssistantModelRoute, type AssistantModelRoute } from "@/lib/llm/models";
|
||||||
import { users } from "@/modules/_core/schema";
|
import { users } from "@/modules/_core/schema";
|
||||||
|
|
||||||
export async function getAssistantEnabled(userId: string): Promise<boolean> {
|
export {
|
||||||
|
DEFAULT_ASSISTANT_NAME,
|
||||||
|
MAX_ASSISTANT_NAME_LENGTH,
|
||||||
|
MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH,
|
||||||
|
resolveAssistantSystemPrompt,
|
||||||
|
} from "@/lib/assistant-config";
|
||||||
|
|
||||||
|
export type AssistantPreferences = {
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
|
systemPrompt: string | null;
|
||||||
|
modelRoute: AssistantModelRoute | null;
|
||||||
|
model: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAssistantPreferences(userId: string): Promise<AssistantPreferences> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ assistantEnabled: users.assistantEnabled })
|
.select({
|
||||||
|
assistantEnabled: users.assistantEnabled,
|
||||||
|
assistantName: users.assistantName,
|
||||||
|
assistantSystemPrompt: users.assistantSystemPrompt,
|
||||||
|
assistantModelRoute: users.assistantModelRoute,
|
||||||
|
assistantModel: users.assistantModel,
|
||||||
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, userId))
|
.where(eq(users.id, userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return row?.assistantEnabled ?? false;
|
return {
|
||||||
|
enabled: row?.assistantEnabled ?? false,
|
||||||
|
name: row?.assistantName?.trim() || DEFAULT_ASSISTANT_NAME,
|
||||||
|
systemPrompt: row?.assistantSystemPrompt ?? null,
|
||||||
|
modelRoute:
|
||||||
|
row?.assistantModelRoute && isValidAssistantModelRoute(row.assistantModelRoute)
|
||||||
|
? row.assistantModelRoute
|
||||||
|
: null,
|
||||||
|
model: row?.assistantModel?.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssistantEnabled(userId: string): Promise<boolean> {
|
||||||
|
const prefs = await getAssistantPreferences(userId);
|
||||||
|
return prefs.enabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ export type WidgetPlacement = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function widgetContentKey(placement: Pick<WidgetPlacement, "widgetId" | "config">): string {
|
export function widgetContentKey(placement: Pick<WidgetPlacement, "widgetId" | "config">): string {
|
||||||
return `${placement.widgetId}::${JSON.stringify(placement.config)}`;
|
return `${placement.widgetId}::${JSON.stringify(placement.config ?? null)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable key for edit-mode widget preview map (index-aligned with layout.widgets). */
|
||||||
|
export function widgetContentIndexKey(index: number): string {
|
||||||
|
return String(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashboardLayout = {
|
export type DashboardLayout = {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ChatContentPart, ChatMessage } from "./types";
|
||||||
|
|
||||||
|
export function textFromMessageContent(content: ChatMessage["content"]): string {
|
||||||
|
if (!content) return "";
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
return content
|
||||||
|
.filter((part): part is Extract<ChatContentPart, { type: "text" }> => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVisionContentParts(text: string, imageDataUrls: string[]): ChatContentPart[] {
|
||||||
|
const parts: ChatContentPart[] = [];
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
parts.push({ type: "text", text: trimmed });
|
||||||
|
} else if (imageDataUrls.length > 0) {
|
||||||
|
parts.push({
|
||||||
|
type: "text",
|
||||||
|
text: "Read this image and help with what the user needs.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const url of imageDataUrls) {
|
||||||
|
parts.push({ type: "image_url", image_url: { url } });
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ export type {
|
|||||||
export { getLlmConfig, isLlmConfigured } from "./config";
|
export { getLlmConfig, isLlmConfigured } from "./config";
|
||||||
export { createMockLlmClient } from "./mock";
|
export { createMockLlmClient } from "./mock";
|
||||||
|
|
||||||
export function createLlmClient(override?: LlmClient): LlmClient {
|
export function createLlmClient(options?: { model?: string; override?: LlmClient }): LlmClient {
|
||||||
if (override) return override;
|
if (options?.override) return options.override;
|
||||||
|
|
||||||
const config = getLlmConfig();
|
const config = getLlmConfig();
|
||||||
if (config.provider === "mock" || !config.baseUrl) {
|
if (config.provider === "mock" || !config.baseUrl) {
|
||||||
@@ -24,6 +24,6 @@ export function createLlmClient(override?: LlmClient): LlmClient {
|
|||||||
return createOpenAiCompatibleClient({
|
return createOpenAiCompatibleClient({
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: config.baseUrl,
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
model: config.model,
|
model: options?.model ?? config.model,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-11
@@ -1,14 +1,5 @@
|
|||||||
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
|
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
|
||||||
|
import { textFromMessageContent } from "./content";
|
||||||
function lastUserText(messages: ChatCompletionRequest["messages"]): string {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
||||||
const message = messages[i];
|
|
||||||
if (message?.role === "user" && message.content) {
|
|
||||||
return message.content.toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function hadToolResults(messages: ChatCompletionRequest["messages"]): boolean {
|
function hadToolResults(messages: ChatCompletionRequest["messages"]): boolean {
|
||||||
return messages.some((message) => message.role === "tool");
|
return messages.some((message) => message.role === "tool");
|
||||||
@@ -17,7 +8,9 @@ function hadToolResults(messages: ChatCompletionRequest["messages"]): boolean {
|
|||||||
export function createMockLlmClient(): LlmClient {
|
export function createMockLlmClient(): LlmClient {
|
||||||
return {
|
return {
|
||||||
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
|
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
|
||||||
const userText = lastUserText(request.messages);
|
const userText = textFromMessageContent(
|
||||||
|
[...request.messages].reverse().find((message) => message.role === "user")?.content ?? "",
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
if (hadToolResults(request.messages)) {
|
if (hadToolResults(request.messages)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { getLlmConfig, type LlmConfig } from "./config";
|
||||||
|
|
||||||
|
export type LlmModelOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ASSISTANT_MODEL_ROUTES = ["auto", "uncensored"] as const;
|
||||||
|
|
||||||
|
export type AssistantModelRoute = (typeof ASSISTANT_MODEL_ROUTES)[number];
|
||||||
|
|
||||||
|
export type LlmModelsResult = {
|
||||||
|
models: LlmModelOption[];
|
||||||
|
fallbackModel: string;
|
||||||
|
route: AssistantModelRoute;
|
||||||
|
degraded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssistantModelResolution =
|
||||||
|
| { ok: true; model: string }
|
||||||
|
| { ok: false; model: string; error: string };
|
||||||
|
|
||||||
|
const MODEL_ID_PATTERN = /^[A-Za-z0-9._:/-]+$/;
|
||||||
|
const MAX_MODEL_ID_LENGTH = 128;
|
||||||
|
|
||||||
|
export function isValidLlmModelId(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return (
|
||||||
|
trimmed.length > 0 &&
|
||||||
|
trimmed.length <= MAX_MODEL_ID_LENGTH &&
|
||||||
|
trimmed === value &&
|
||||||
|
MODEL_ID_PATTERN.test(trimmed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidAssistantModelRoute(value: string): value is AssistantModelRoute {
|
||||||
|
return ASSISTANT_MODEL_ROUTES.some((route) => route === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelRoute(
|
||||||
|
route: AssistantModelRoute | null | undefined,
|
||||||
|
config: LlmConfig,
|
||||||
|
): { route: AssistantModelRoute; fallbackModel: string } {
|
||||||
|
if (route) {
|
||||||
|
return { route, fallbackModel: route };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRoute: AssistantModelRoute = config.model === "uncensored" ? "uncensored" : "auto";
|
||||||
|
return { route: defaultRoute, fallbackModel: config.model };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLlmModelsPayload(payload: unknown): LlmModelOption[] {
|
||||||
|
const data =
|
||||||
|
typeof payload === "object" && payload !== null && "data" in payload
|
||||||
|
? (payload as { data?: unknown }).data
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const row of data) {
|
||||||
|
if (typeof row !== "object" || row === null || !("id" in row)) continue;
|
||||||
|
const id = (row as { id?: unknown }).id;
|
||||||
|
if (typeof id !== "string") continue;
|
||||||
|
if (!isValidLlmModelId(id)) continue;
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ids].sort((a, b) => a.localeCompare(b)).map((id) => ({ id, label: id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLlmModels(options?: {
|
||||||
|
config?: LlmConfig;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
route?: AssistantModelRoute | null;
|
||||||
|
}): Promise<LlmModelsResult> {
|
||||||
|
const config = options?.config ?? getLlmConfig();
|
||||||
|
const fetchImpl = options?.fetchImpl ?? fetch;
|
||||||
|
const { route, fallbackModel } = resolveModelRoute(options?.route, config);
|
||||||
|
const fallbackOption = { id: fallbackModel, label: fallbackModel };
|
||||||
|
|
||||||
|
if (config.provider === "mock" || !config.baseUrl) {
|
||||||
|
return { models: [fallbackOption], fallbackModel, route, degraded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||||
|
|
||||||
|
const modelsUrl = new URL(`${config.baseUrl.replace(/\/$/, "")}/models`);
|
||||||
|
if (route === "uncensored") {
|
||||||
|
modelsUrl.searchParams.set("type", "uncensored");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchImpl(modelsUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { models: [fallbackOption], fallbackModel, route, degraded: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = normalizeLlmModelsPayload(await response.json());
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
return { models: [fallbackOption], fallbackModel, route, degraded: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!models.some((model) => model.id === fallbackModel)) {
|
||||||
|
return { models: [fallbackOption], fallbackModel, route, degraded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
fallbackModel,
|
||||||
|
route,
|
||||||
|
degraded: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { models: [fallbackOption], fallbackModel, route, degraded: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAssistantModel(options: {
|
||||||
|
requestedModel: string | null | undefined;
|
||||||
|
savedModel: string | null | undefined;
|
||||||
|
fallbackModel: string;
|
||||||
|
models: LlmModelOption[];
|
||||||
|
}): AssistantModelResolution {
|
||||||
|
const available = new Set(options.models.map((model) => model.id));
|
||||||
|
const fallback = available.has(options.fallbackModel)
|
||||||
|
? options.fallbackModel
|
||||||
|
: (options.models[0]?.id ?? options.fallbackModel);
|
||||||
|
|
||||||
|
if (options.requestedModel !== null && options.requestedModel !== undefined) {
|
||||||
|
if (!isValidLlmModelId(options.requestedModel) || !available.has(options.requestedModel)) {
|
||||||
|
return { ok: false, model: fallback, error: "Invalid assistant model" };
|
||||||
|
}
|
||||||
|
return { ok: true, model: options.requestedModel };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.savedModel && available.has(options.savedModel)) {
|
||||||
|
return { ok: true, model: options.savedModel };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, model: fallback };
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
|
import type { ChatCompletionRequest, ChatCompletionResult, ChatMessage, LlmClient } from "./types";
|
||||||
|
|
||||||
type OpenAiMessage = {
|
type OpenAiMessage = {
|
||||||
role: string;
|
role: string;
|
||||||
content: string | null;
|
content: ChatMessage["content"];
|
||||||
tool_calls?: Array<{
|
tool_calls?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
type: "function";
|
type: "function";
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { getLlmConfig } from "./config";
|
||||||
|
|
||||||
|
const MAX_AUDIO_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function transcribeAudioFile(file: File | Blob, filename: string): Promise<string> {
|
||||||
|
if (file.size > MAX_AUDIO_BYTES) {
|
||||||
|
throw new Error("Recording exceeds 25 MB limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getLlmConfig();
|
||||||
|
if (config.provider === "mock" || !config.baseUrl) {
|
||||||
|
return "add milk to the shopping list";
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file, filename);
|
||||||
|
formData.append("model", "whisper-1");
|
||||||
|
|
||||||
|
const url = `${config.baseUrl.replace(/\/$/, "")}/audio/transcriptions`;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (config.apiKey) {
|
||||||
|
headers.Authorization = `Bearer ${config.apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text();
|
||||||
|
throw new Error(`Transcription failed (${response.status}): ${detail.slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
const payload = (await response.json()) as { text?: string };
|
||||||
|
const text = payload.text?.trim();
|
||||||
|
if (!text) throw new Error("Transcription returned empty text");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (await response.text()).trim();
|
||||||
|
if (!text) throw new Error("Transcription returned empty text");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
+15
-1
@@ -1,5 +1,19 @@
|
|||||||
export type ChatRole = "system" | "user" | "assistant" | "tool";
|
export type ChatRole = "system" | "user" | "assistant" | "tool";
|
||||||
|
|
||||||
|
export type ChatTextPart = {
|
||||||
|
type: "text";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatImagePart = {
|
||||||
|
type: "image_url";
|
||||||
|
image_url: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatContentPart = ChatTextPart | ChatImagePart;
|
||||||
|
|
||||||
export type ChatToolCall = {
|
export type ChatToolCall = {
|
||||||
id: string;
|
id: string;
|
||||||
type: "function";
|
type: "function";
|
||||||
@@ -11,7 +25,7 @@ export type ChatToolCall = {
|
|||||||
|
|
||||||
export type ChatMessage = {
|
export type ChatMessage = {
|
||||||
role: ChatRole;
|
role: ChatRole;
|
||||||
content: string | null;
|
content: string | ChatContentPart[] | null;
|
||||||
tool_calls?: ChatToolCall[];
|
tool_calls?: ChatToolCall[];
|
||||||
tool_call_id?: string;
|
tool_call_id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type ReminderOffsetUnit = "minutes" | "hours" | "days" | "weeks";
|
||||||
|
|
||||||
|
export type ReminderPreset = {
|
||||||
|
label: string;
|
||||||
|
offsetMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REMINDER_PRESETS: ReminderPreset[] = [
|
||||||
|
{ label: "At event time", offsetMinutes: 0 },
|
||||||
|
{ label: "5 minutes before", offsetMinutes: 5 },
|
||||||
|
{ label: "15 minutes before", offsetMinutes: 15 },
|
||||||
|
{ label: "30 minutes before", offsetMinutes: 30 },
|
||||||
|
{ label: "1 hour before", offsetMinutes: 60 },
|
||||||
|
{ label: "2 hours before", offsetMinutes: 120 },
|
||||||
|
{ label: "1 day before", offsetMinutes: 1440 },
|
||||||
|
{ label: "2 days before", offsetMinutes: 2880 },
|
||||||
|
{ label: "1 week before", offsetMinutes: 10080 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function formatReminderOffset(offsetMinutes: number): string {
|
||||||
|
const preset = REMINDER_PRESETS.find((p) => p.offsetMinutes === offsetMinutes);
|
||||||
|
if (preset) return preset.label;
|
||||||
|
|
||||||
|
if (offsetMinutes === 0) return "At event time";
|
||||||
|
if (offsetMinutes % 10080 === 0) {
|
||||||
|
const weeks = offsetMinutes / 10080;
|
||||||
|
return weeks === 1 ? "1 week before" : `${weeks} weeks before`;
|
||||||
|
}
|
||||||
|
if (offsetMinutes % 1440 === 0) {
|
||||||
|
const days = offsetMinutes / 1440;
|
||||||
|
return days === 1 ? "1 day before" : `${days} days before`;
|
||||||
|
}
|
||||||
|
if (offsetMinutes % 60 === 0) {
|
||||||
|
const hours = offsetMinutes / 60;
|
||||||
|
return hours === 1 ? "1 hour before" : `${hours} hours before`;
|
||||||
|
}
|
||||||
|
return offsetMinutes === 1 ? "1 minute before" : `${offsetMinutes} minutes before`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customOffsetToMinutes(value: number, unit: ReminderOffsetUnit): number {
|
||||||
|
const safe = Math.max(0, Math.floor(value));
|
||||||
|
switch (unit) {
|
||||||
|
case "minutes":
|
||||||
|
return safe;
|
||||||
|
case "hours":
|
||||||
|
return safe * 60;
|
||||||
|
case "days":
|
||||||
|
return safe * 1440;
|
||||||
|
case "weeks":
|
||||||
|
return safe * 10080;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReminderOffsets(offsets: number[]): number[] {
|
||||||
|
return [...new Set(offsets.filter((n) => Number.isFinite(n) && n >= 0))].toSorted(
|
||||||
|
(a, b) => b - a,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fireAtForEventStart(startAt: Date, offsetMinutes: number): Date {
|
||||||
|
return new Date(startAt.getTime() - offsetMinutes * 60_000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { comments, users } from "./schema";
|
||||||
|
|
||||||
|
export type CommentDto = {
|
||||||
|
id: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
body: string;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commentInput = z.object({
|
||||||
|
entityType: z.string().trim().min(1).max(80),
|
||||||
|
entityId: z.string().uuid(),
|
||||||
|
body: z.string().trim().min(1).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listComments(entityType: string, entityId: string): Promise<CommentDto[]> {
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const parsedEntityId = z.string().uuid().parse(entityId);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
entityType: comments.entityType,
|
||||||
|
entityId: comments.entityId,
|
||||||
|
body: comments.body,
|
||||||
|
authorId: comments.authorId,
|
||||||
|
authorName: users.name,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.innerJoin(users, eq(comments.authorId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(comments.householdId, household.id),
|
||||||
|
eq(comments.entityType, entityType),
|
||||||
|
eq(comments.entityId, parsedEntityId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(comments.createdAt));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
entityType: row.entityType,
|
||||||
|
entityId: row.entityId,
|
||||||
|
body: row.body,
|
||||||
|
authorId: row.authorId,
|
||||||
|
authorName: row.authorName,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addComment(input: z.input<typeof commentInput>): Promise<CommentDto> {
|
||||||
|
const parsed = commentInput.parse(input);
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(comments)
|
||||||
|
.values({
|
||||||
|
householdId: household.id,
|
||||||
|
entityType: parsed.entityType,
|
||||||
|
entityId: parsed.entityId,
|
||||||
|
authorId: user.id,
|
||||||
|
body: parsed.body,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) throw new Error("Comment was not created");
|
||||||
|
|
||||||
|
revalidatePath("/lists");
|
||||||
|
revalidatePath("/notes");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
entityType: row.entityType,
|
||||||
|
entityId: row.entityId,
|
||||||
|
body: row.body,
|
||||||
|
authorId: row.authorId,
|
||||||
|
authorName: user.name,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComment(input: { id: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: comments.id, authorId: comments.authorId })
|
||||||
|
.from(comments)
|
||||||
|
.where(and(eq(comments.id, parsed.id), eq(comments.householdId, household.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Comment not found");
|
||||||
|
if (existing.authorId !== user.id) throw new Error("Forbidden");
|
||||||
|
|
||||||
|
await db.delete(comments).where(eq(comments.id, parsed.id));
|
||||||
|
revalidatePath("/lists");
|
||||||
|
revalidatePath("/notes");
|
||||||
|
}
|
||||||
@@ -24,4 +24,13 @@ export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
|||||||
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
||||||
export { sendPush, sendPushToEndpoint } from "./push";
|
export { sendPush, sendPushToEndpoint } from "./push";
|
||||||
export { notify } from "./notify";
|
export { notify } from "./notify";
|
||||||
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders";
|
export {
|
||||||
|
scheduleReminder,
|
||||||
|
cancelReminder,
|
||||||
|
listReminders,
|
||||||
|
listReminderOffsets,
|
||||||
|
syncRemindersForEntity,
|
||||||
|
syncCalendarEventReminders,
|
||||||
|
startReminderWorker,
|
||||||
|
} from "./reminders";
|
||||||
|
export { listComments, addComment, deleteComment, type CommentDto } from "./comments";
|
||||||
|
|||||||
+115
-17
@@ -1,11 +1,19 @@
|
|||||||
import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm";
|
import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
import { fireAtForEventStart } from "@/lib/reminder-offsets";
|
||||||
import { reminders } from "./schema";
|
import { reminders } from "./schema";
|
||||||
import { notify } from "./notify";
|
import { notify } from "./notify";
|
||||||
|
|
||||||
const REMINDER_LOCK_KEY = 7_777_777;
|
const REMINDER_LOCK_KEY = 7_777_777;
|
||||||
|
|
||||||
|
type ReminderInput = {
|
||||||
|
fireAt: Date;
|
||||||
|
offsetMinutes?: number | null;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function scheduleReminder(input: {
|
export async function scheduleReminder(input: {
|
||||||
householdId: string;
|
householdId: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@@ -15,30 +23,92 @@ export async function scheduleReminder(input: {
|
|||||||
channel?: string;
|
channel?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
offsetMinutes?: number | null;
|
||||||
}) {
|
}) {
|
||||||
await db
|
await db
|
||||||
.insert(reminders)
|
.delete(reminders)
|
||||||
.values({
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminders.entityType, input.entityType),
|
||||||
|
eq(reminders.entityId, input.entityId),
|
||||||
|
isNull(reminders.firedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.insert(reminders).values({
|
||||||
|
householdId: input.householdId,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
fireAt: input.fireAt,
|
||||||
|
offsetMinutes: input.offsetMinutes ?? null,
|
||||||
|
channel: input.channel ?? "auto",
|
||||||
|
title: input.title ?? null,
|
||||||
|
body: input.body ?? null,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
firedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncRemindersForEntity(input: {
|
||||||
|
householdId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
createdBy: string;
|
||||||
|
channel?: string;
|
||||||
|
reminders: ReminderInput[];
|
||||||
|
}) {
|
||||||
|
await db
|
||||||
|
.delete(reminders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminders.entityType, input.entityType),
|
||||||
|
eq(reminders.entityId, input.entityId),
|
||||||
|
isNull(reminders.firedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const rows = input.reminders.filter((r) => r.fireAt > now);
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
await db.insert(reminders).values(
|
||||||
|
rows.map((row) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
fireAt: input.fireAt,
|
fireAt: row.fireAt,
|
||||||
|
offsetMinutes: row.offsetMinutes ?? null,
|
||||||
channel: input.channel ?? "auto",
|
channel: input.channel ?? "auto",
|
||||||
title: input.title ?? null,
|
title: row.title ?? null,
|
||||||
body: input.body ?? null,
|
body: row.body ?? null,
|
||||||
createdBy: input.createdBy,
|
createdBy: input.createdBy,
|
||||||
firedAt: null,
|
firedAt: null,
|
||||||
})
|
})),
|
||||||
.onConflictDoUpdate({
|
);
|
||||||
target: [reminders.entityType, reminders.entityId],
|
}
|
||||||
set: {
|
|
||||||
fireAt: input.fireAt,
|
export async function syncCalendarEventReminders(input: {
|
||||||
title: input.title ?? null,
|
householdId: string;
|
||||||
body: input.body ?? null,
|
eventId: string;
|
||||||
firedAt: null,
|
eventTitle: string;
|
||||||
createdBy: input.createdBy,
|
startAt: Date;
|
||||||
},
|
createdBy: string;
|
||||||
});
|
offsetMinutes: number[];
|
||||||
|
}) {
|
||||||
|
const remindersToSchedule = input.offsetMinutes.map((offset) => ({
|
||||||
|
fireAt: fireAtForEventStart(input.startAt, offset),
|
||||||
|
offsetMinutes: offset,
|
||||||
|
title: input.eventTitle,
|
||||||
|
body: formatReminderOffsetBody(offset, input.eventTitle),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await syncRemindersForEntity({
|
||||||
|
householdId: input.householdId,
|
||||||
|
entityType: "calendar.event",
|
||||||
|
entityId: input.eventId,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
reminders: remindersToSchedule,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelReminder(entityType: string, entityId: string) {
|
export async function cancelReminder(entityType: string, entityId: string) {
|
||||||
@@ -54,6 +124,29 @@ export async function listReminders(entityType: string, entityId: string) {
|
|||||||
.where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId)));
|
.where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listReminderOffsets(entityType: string, entityId: string): Promise<number[]> {
|
||||||
|
const rows = await listReminders(entityType, entityId);
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.offsetMinutes != null && row.firedAt == null)
|
||||||
|
.map((row) => row.offsetMinutes!)
|
||||||
|
.toSorted((a, b) => b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReminderOffsetBody(offsetMinutes: number, title: string): string {
|
||||||
|
if (offsetMinutes === 0) return `${title} is starting now`;
|
||||||
|
if (offsetMinutes % 1440 === 0) {
|
||||||
|
const days = offsetMinutes / 1440;
|
||||||
|
return days === 1 ? `${title} starts in 1 day` : `${title} starts in ${days} days`;
|
||||||
|
}
|
||||||
|
if (offsetMinutes % 60 === 0) {
|
||||||
|
const hours = offsetMinutes / 60;
|
||||||
|
return hours === 1 ? `${title} starts in 1 hour` : `${title} starts in ${hours} hours`;
|
||||||
|
}
|
||||||
|
return offsetMinutes === 1
|
||||||
|
? `${title} starts in 1 minute`
|
||||||
|
: `${title} starts in ${offsetMinutes} minutes`;
|
||||||
|
}
|
||||||
|
|
||||||
async function tickReminders() {
|
async function tickReminders() {
|
||||||
let dueReminders: (typeof reminders.$inferSelect)[] = [];
|
let dueReminders: (typeof reminders.$inferSelect)[] = [];
|
||||||
|
|
||||||
@@ -94,7 +187,12 @@ async function tickReminders() {
|
|||||||
await notify(reminder.createdBy, {
|
await notify(reminder.createdBy, {
|
||||||
title: reminder.title ?? "Reminder",
|
title: reminder.title ?? "Reminder",
|
||||||
body: reminder.body ?? "You have a reminder",
|
body: reminder.body ?? "You have a reminder",
|
||||||
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/",
|
url:
|
||||||
|
reminder.entityType === "notes.note"
|
||||||
|
? `/notes/${reminder.entityId}`
|
||||||
|
: reminder.entityType === "calendar.event"
|
||||||
|
? "/calendar"
|
||||||
|
: "/",
|
||||||
channels: ["push", "inapp"],
|
channels: ["push", "inapp"],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export const users = pgTable("users", {
|
|||||||
notifInApp: boolean("notif_inapp").notNull().default(true),
|
notifInApp: boolean("notif_inapp").notNull().default(true),
|
||||||
notifNtfy: boolean("notif_ntfy").notNull().default(false),
|
notifNtfy: boolean("notif_ntfy").notNull().default(false),
|
||||||
assistantEnabled: boolean("assistant_enabled").notNull().default(false),
|
assistantEnabled: boolean("assistant_enabled").notNull().default(false),
|
||||||
|
assistantName: text("assistant_name").notNull().default("Assistant"),
|
||||||
|
assistantSystemPrompt: text("assistant_system_prompt"),
|
||||||
|
assistantModelRoute: text("assistant_model_route"),
|
||||||
|
assistantModel: text("assistant_model"),
|
||||||
|
defaultEventReminderOffsets: jsonb("default_event_reminder_offsets")
|
||||||
|
.notNull()
|
||||||
|
.$type<number[]>()
|
||||||
|
.default([30]),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,12 +178,13 @@ export const reminders = pgTable(
|
|||||||
channel: text("channel").notNull().default("auto"),
|
channel: text("channel").notNull().default("auto"),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
body: text("body"),
|
body: text("body"),
|
||||||
|
offsetMinutes: integer("offset_minutes"),
|
||||||
firedAt: timestamp("fired_at", { withTimezone: true }),
|
firedAt: timestamp("fired_at", { withTimezone: true }),
|
||||||
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
uniqueIndex("reminders_entity_unique").on(t.entityType, t.entityId),
|
index("reminders_entity_idx").on(t.entityType, t.entityId),
|
||||||
index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt),
|
index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -212,6 +221,25 @@ export const notifications = pgTable(
|
|||||||
(t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)],
|
(t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const comments = pgTable(
|
||||||
|
"comments",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
householdId: uuid("household_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: "cascade" }),
|
||||||
|
entityType: text("entity_type").notNull(),
|
||||||
|
entityId: uuid("entity_id").notNull(),
|
||||||
|
authorId: uuid("author_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index("comments_entity_idx").on(t.entityType, t.entityId, t.createdAt)],
|
||||||
|
);
|
||||||
|
|
||||||
export const householdApiTokens = pgTable(
|
export const householdApiTokens = pgTable(
|
||||||
"household_api_tokens",
|
"household_api_tokens",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createHash, randomBytes } from "crypto";
|
import { createHash, randomBytes } from "crypto";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import type { ApiAuthContext } from "@/lib/api-auth";
|
import type { ApiAuthContext } from "@/lib/api-auth";
|
||||||
|
import { getAppPublicUrl } from "@/lib/app-url";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getEntityType, getRegistry } from "./registry";
|
import { getEntityType, getRegistry } from "./registry";
|
||||||
import { shareLinks } from "./schema";
|
import { shareLinks } from "./schema";
|
||||||
@@ -27,9 +28,7 @@ function hashToken(raw: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(token: string): string {
|
function buildUrl(token: string): string {
|
||||||
const base =
|
return `${getAppPublicUrl()}/s/${token}`;
|
||||||
process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000";
|
|
||||||
return `${base}/s/${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listShareableEntityTypes() {
|
export function listShareableEntityTypes() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { createHash, randomBytes } from "crypto";
|
import { createHash, randomBytes } from "crypto";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { getAppPublicUrl } from "@/lib/app-url";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { getEntityType } from "./registry";
|
import { getEntityType } from "./registry";
|
||||||
@@ -21,9 +22,7 @@ function hashToken(raw: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(token: string): string {
|
function buildUrl(token: string): string {
|
||||||
const base =
|
return `${getAppPublicUrl()}/s/${token}`;
|
||||||
process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000";
|
|
||||||
return `${base}/s/${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createShareLink(
|
export async function createShareLink(
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import type { ClientChatMessage } from "./messages";
|
||||||
|
|
||||||
export type AssistantChatMessage = {
|
export type AssistantChatMessage = {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
toolCalls?: Array<{ name: string; status: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_VERSION = "v1";
|
const STORAGE_VERSION = "v2";
|
||||||
const MAX_MESSAGES = 40;
|
const MAX_MESSAGES = 40;
|
||||||
|
|
||||||
function storageKey(userId: string) {
|
function storageKey(userId: string) {
|
||||||
@@ -13,11 +17,30 @@ function storageKey(userId: string) {
|
|||||||
function isValidMessage(value: unknown): value is AssistantChatMessage {
|
function isValidMessage(value: unknown): value is AssistantChatMessage {
|
||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
const row = value as Record<string, unknown>;
|
const row = value as Record<string, unknown>;
|
||||||
return (
|
if (row.role !== "user" && row.role !== "assistant") return false;
|
||||||
(row.role === "user" || row.role === "assistant") &&
|
if (typeof row.content !== "string" || row.content.trim().length === 0) return false;
|
||||||
typeof row.content === "string" &&
|
if (row.imageUrl !== undefined && typeof row.imageUrl !== "string") return false;
|
||||||
row.content.trim().length > 0
|
if (row.toolCalls !== undefined) {
|
||||||
);
|
if (!Array.isArray(row.toolCalls)) return false;
|
||||||
|
for (const call of row.toolCalls) {
|
||||||
|
if (!call || typeof call !== "object") return false;
|
||||||
|
const entry = call as Record<string, unknown>;
|
||||||
|
if (typeof entry.name !== "string" || typeof entry.status !== "number") return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toClientChatMessage(message: AssistantChatMessage): ClientChatMessage {
|
||||||
|
if (!message.imageUrl) {
|
||||||
|
return { role: message.role, content: message.content };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
attachments: [{ type: "image", url: message.imageUrl }],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAssistantChat(userId: string): AssistantChatMessage[] {
|
export function loadAssistantChat(userId: string): AssistantChatMessage[] {
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { AssistantPanel } from "./assistant-panel";
|
|||||||
type Props = {
|
type Props = {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
assistantName: string;
|
||||||
|
assistantModel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AssistantBubble({ configured, userId }: Props) {
|
export function AssistantBubble({ configured, userId, assistantName, assistantModel }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,31 +20,37 @@ export function AssistantBubble({ configured, userId }: Props) {
|
|||||||
<div
|
<div
|
||||||
className="assistant-bubble-panel"
|
className="assistant-bubble-panel"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Assistant"
|
aria-label={assistantName}
|
||||||
aria-modal="false"
|
aria-modal="false"
|
||||||
>
|
>
|
||||||
<div className="assistant-bubble-header">
|
<div className="assistant-bubble-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="serif text-[15px] font-medium tracking-tight">Assistant</div>
|
<div className="serif text-[15px] font-medium tracking-tight">{assistantName}</div>
|
||||||
<div className="muted text-[11px]">Household helper</div>
|
<div className="muted text-[11px]">Household helper</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="assistant-bubble-close"
|
className="assistant-bubble-close"
|
||||||
aria-label="Close assistant"
|
aria-label={`Close ${assistantName}`}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<AssistantPanel key={userId} configured={configured} userId={userId} />
|
<AssistantPanel
|
||||||
|
key={userId}
|
||||||
|
configured={configured}
|
||||||
|
userId={userId}
|
||||||
|
assistantName={assistantName}
|
||||||
|
assistantModel={assistantModel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="assistant-bubble-trigger"
|
className="assistant-bubble-trigger"
|
||||||
aria-label={open ? "Close assistant" : "Open assistant"}
|
aria-label={open ? `Close ${assistantName}` : `Open ${assistantName}`}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((current) => !current)}
|
onClick={() => setOpen((current) => !current)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { Loader2, Send } from "lucide-react";
|
import { ChevronDown, ImagePlus, Loader2, Mic, RefreshCw, Send, Square } from "lucide-react";
|
||||||
|
import { setAssistantModel } from "@/app/settings/assistant-actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { consumeAgentChatStream } from "../assistant-chat-stream";
|
import { consumeAgentChatStream } from "../assistant-chat-stream";
|
||||||
@@ -9,22 +10,72 @@ import {
|
|||||||
clearAssistantChat,
|
clearAssistantChat,
|
||||||
loadAssistantChat,
|
loadAssistantChat,
|
||||||
saveAssistantChat,
|
saveAssistantChat,
|
||||||
|
toClientChatMessage,
|
||||||
type AssistantChatMessage,
|
type AssistantChatMessage,
|
||||||
} from "../assistant-chat-storage";
|
} from "../assistant-chat-storage";
|
||||||
|
import { useVoiceInput } from "./use-voice-input";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
assistantName: string;
|
||||||
|
assistantModel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AssistantPanel({ configured, userId }: Props) {
|
type PendingImage = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LlmModelOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModelsResponse = {
|
||||||
|
models: LlmModelOption[];
|
||||||
|
selectedModel: string;
|
||||||
|
fallbackModel: string;
|
||||||
|
degraded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function uploadAssistantImage(file: File): Promise<string> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const response = await fetch("/api/uploads?scope=assistant", { method: "POST", body: formData });
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
throw new Error(payload?.error ?? "Image upload failed");
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as { url: string };
|
||||||
|
return payload.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssistantPanel({ configured, userId, assistantName, assistantModel }: Props) {
|
||||||
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
|
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
const [pendingImage, setPendingImage] = useState<PendingImage | null>(null);
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [activityLabel, setActivityLabel] = useState<string | null>(null);
|
const [activityLabel, setActivityLabel] = useState<string | null>(null);
|
||||||
|
const [modelOptions, setModelOptions] = useState<LlmModelOption[]>([]);
|
||||||
|
const [selectedModel, setSelectedModel] = useState(assistantModel ?? "");
|
||||||
|
const [fallbackModel, setFallbackModel] = useState("");
|
||||||
|
const [modelsDegraded, setModelsDegraded] = useState(false);
|
||||||
|
const [modelsLoading, setModelsLoading] = useState(true);
|
||||||
|
const [savingModel, startTransition] = useTransition();
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { state: voiceState, toggleRecording } = useVoiceInput({
|
||||||
|
disabled: isPending || uploadingImage,
|
||||||
|
onTranscript: (text) => {
|
||||||
|
setInput((current) => (current.trim() ? `${current.trim()} ${text}` : text));
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (message) => setError(message),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveAssistantChat(userId, messages);
|
saveAssistantChat(userId, messages);
|
||||||
@@ -36,6 +87,48 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadModels = useCallback(async (options?: { refresh?: boolean; signal?: AbortSignal }) => {
|
||||||
|
if (options?.signal?.aborted) return;
|
||||||
|
|
||||||
|
setModelsLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.refresh) params.set("refresh", "1");
|
||||||
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
|
|
||||||
|
const response = await fetch(`/api/agent/models${query}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Model discovery unavailable");
|
||||||
|
const payload = (await response.json()) as ModelsResponse;
|
||||||
|
if (options?.signal?.aborted) return;
|
||||||
|
setModelOptions(payload.models);
|
||||||
|
setSelectedModel(payload.selectedModel);
|
||||||
|
setFallbackModel(payload.fallbackModel);
|
||||||
|
setModelsDegraded(payload.degraded);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
|
setModelsDegraded(true);
|
||||||
|
setError("Model discovery unavailable");
|
||||||
|
} finally {
|
||||||
|
if (!options?.signal?.aborted) setModelsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadModels({ signal: controller.signal });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [loadModels]);
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const node = listRef.current;
|
const node = listRef.current;
|
||||||
@@ -46,22 +139,65 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
function clearChat() {
|
function clearChat() {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setInput("");
|
||||||
|
setPendingImage(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setActivityLabel(null);
|
setActivityLabel(null);
|
||||||
setIsPending(false);
|
setIsPending(false);
|
||||||
clearAssistantChat(userId);
|
clearAssistantChat(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleImageSelect(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file || isPending || uploadingImage) return;
|
||||||
|
|
||||||
|
setUploadingImage(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const url = await uploadAssistantImage(file);
|
||||||
|
setPendingImage({ url });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Image upload failed");
|
||||||
|
} finally {
|
||||||
|
setUploadingImage(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeModel(nextModel: string | null) {
|
||||||
|
if (!nextModel) return;
|
||||||
|
|
||||||
|
setSelectedModel(nextModel);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await setAssistantModel(nextModel === fallbackModel ? null : nextModel);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not save assistant model");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (!text || isPending) return;
|
const hasImage = pendingImage !== null;
|
||||||
|
if ((!text && !hasImage) || isPending || voiceState !== "idle") return;
|
||||||
|
|
||||||
const nextMessages: AssistantChatMessage[] = [...messages, { role: "user", content: text }];
|
const content = text || "Help me with this image.";
|
||||||
|
const userMessage: AssistantChatMessage = {
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
...(pendingImage ? { imageUrl: pendingImage.url } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMessages: AssistantChatMessage[] = [...messages, userMessage];
|
||||||
setInput("");
|
setInput("");
|
||||||
|
setPendingImage(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
setIsPending(true);
|
setIsPending(true);
|
||||||
setActivityLabel("Understanding your request…");
|
setActivityLabel(hasImage ? "Reading your photo…" : "Understanding your request…");
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
@@ -72,7 +208,11 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
const response = await fetch("/api/agent/chat", {
|
const response = await fetch("/api/agent/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ messages: nextMessages, stream: true }),
|
body: JSON.stringify({
|
||||||
|
messages: nextMessages.map(toClientChatMessage),
|
||||||
|
model: selectedModel || undefined,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +227,14 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
throw new Error("Assistant returned an empty response");
|
throw new Error("Assistant returned an empty response");
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages((current) => [...current, result.message]);
|
setMessages((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: result.message.content,
|
||||||
|
...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === "AbortError") return;
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
@@ -100,25 +247,74 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showEmptyState = messages.length === 0 && !isPending;
|
const showEmptyState = messages.length === 0 && !isPending;
|
||||||
|
const inputDisabled = isPending || voiceState === "transcribing" || uploadingImage;
|
||||||
|
const canSend =
|
||||||
|
!isPending &&
|
||||||
|
!savingModel &&
|
||||||
|
voiceState === "idle" &&
|
||||||
|
!uploadingImage &&
|
||||||
|
(input.trim().length > 0 || pendingImage !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="muted min-w-0 text-[12px] leading-relaxed">
|
<div className="min-w-0 flex-1">
|
||||||
{configured
|
<p className="muted text-[12px] leading-relaxed">
|
||||||
? "Ask me to update lists, calendar, notes, or journal."
|
{configured
|
||||||
: "Mock provider active — set LLM_BASE_URL for your homelab model."}
|
? "Type, talk, or send a photo — I can update lists, calendar, notes, and more."
|
||||||
</p>
|
: "Mock provider active — set LLM_BASE_URL for your homelab model."}
|
||||||
{messages.length > 0 ? (
|
</p>
|
||||||
<button
|
{modelsDegraded ? (
|
||||||
|
<p className="muted mt-1 text-[11px]">Model discovery unavailable; using fallback.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{modelOptions.length > 0 ? (
|
||||||
|
<div className="relative max-w-36">
|
||||||
|
<select
|
||||||
|
aria-label="Assistant model"
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(event) => changeModel(event.target.value)}
|
||||||
|
disabled={modelsLoading || savingModel || isPending}
|
||||||
|
className="h-9 w-full max-w-36 appearance-none truncate rounded-[min(var(--radius-md),10px)] border border-input bg-[var(--card)] py-0 pr-8 pl-3 text-[13px] leading-9 text-[var(--ink)] outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{modelOptions.map((model) => (
|
||||||
|
<option key={model.id} value={model.id}>
|
||||||
|
{model.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown
|
||||||
|
className="pointer-events-none absolute top-1/2 right-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearChat}
|
size="sm"
|
||||||
disabled={isPending}
|
variant="outline"
|
||||||
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
aria-label="Refresh assistant models"
|
||||||
|
disabled={modelsLoading || savingModel || isPending}
|
||||||
|
onClick={() => void loadModels({ refresh: true })}
|
||||||
>
|
>
|
||||||
Clear
|
{modelsLoading ? (
|
||||||
</button>
|
<Loader2 className="size-4 animate-spin" />
|
||||||
) : null}
|
) : (
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{messages.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearChat}
|
||||||
|
disabled={isPending}
|
||||||
|
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -128,8 +324,8 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
>
|
>
|
||||||
{showEmptyState ? (
|
{showEmptyState ? (
|
||||||
<p className="muted text-[12px]">
|
<p className="muted text-[12px]">
|
||||||
Try "add milk to the shopping list" or "what's on the calendar this
|
Try "add milk to the shopping list", tap the mic, or attach a photo of an
|
||||||
week?"
|
appointment card.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -144,9 +340,24 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
|
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
|
||||||
>
|
>
|
||||||
<div className="eyebrow mb-0.5 text-[10px]">
|
<div className="eyebrow mb-0.5 text-[10px]">
|
||||||
{message.role === "user" ? "You" : "Assistant"}
|
{message.role === "user" ? "You" : assistantName}
|
||||||
</div>
|
</div>
|
||||||
|
{message.imageUrl ? (
|
||||||
|
// User-uploaded assistant attachment preview
|
||||||
|
<img
|
||||||
|
src={message.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="mb-2 max-h-40 w-full rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{message.content}
|
{message.content}
|
||||||
|
{message.role === "assistant" &&
|
||||||
|
message.toolCalls &&
|
||||||
|
message.toolCalls.length > 0 ? (
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
{message.toolCalls.map((call) => `${call.name}→${call.status}`).join(" · ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -157,7 +368,7 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
>
|
>
|
||||||
<div className="eyebrow mb-1 text-[10px]">Assistant</div>
|
<div className="eyebrow mb-1 text-[10px]">{assistantName}</div>
|
||||||
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
|
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
|
||||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||||
<span>{activityLabel ?? "Working…"}</span>
|
<span>{activityLabel ?? "Working…"}</span>
|
||||||
@@ -168,24 +379,87 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{pendingImage ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-[var(--r-md)] border-[0.5px] bg-[var(--shade)] p-2">
|
||||||
|
<img
|
||||||
|
src={pendingImage.url}
|
||||||
|
alt=""
|
||||||
|
className="max-h-16 max-w-[40%] rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1 text-[11px] text-muted-foreground">Photo attached</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setPendingImage(null)}
|
||||||
|
disabled={inputDisabled}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? <p className="text-[12px] text-destructive">{error}</p> : null}
|
{error ? <p className="text-[12px] text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="flex gap-2"
|
className="flex gap-2"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
sendMessage();
|
void sendMessage();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => void handleImageSelect(event)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={inputDisabled}
|
||||||
|
aria-label="Attach photo"
|
||||||
|
onClick={() => imageInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploadingImage ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImagePlus className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={voiceState === "recording" ? "destructive" : "outline"}
|
||||||
|
disabled={inputDisabled}
|
||||||
|
aria-label={voiceState === "recording" ? "Stop recording" : "Record voice message"}
|
||||||
|
aria-pressed={voiceState === "recording"}
|
||||||
|
onClick={() => void toggleRecording()}
|
||||||
|
>
|
||||||
|
{voiceState === "transcribing" ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : voiceState === "recording" ? (
|
||||||
|
<Square className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Mic className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Input
|
<Input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => setInput(event.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
placeholder="Ask the assistant…"
|
placeholder={
|
||||||
disabled={isPending}
|
voiceState === "recording"
|
||||||
aria-label="Assistant message"
|
? "Listening… tap mic to stop"
|
||||||
className="h-9"
|
: voiceState === "transcribing"
|
||||||
|
? "Transcribing…"
|
||||||
|
: `Ask ${assistantName}…`
|
||||||
|
}
|
||||||
|
disabled={inputDisabled}
|
||||||
|
aria-label={`Message for ${assistantName}`}
|
||||||
|
className="h-9 min-w-0 flex-1"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm" disabled={isPending || !input.trim()} aria-label="Send">
|
<Button type="submit" size="sm" disabled={!canSend} aria-label="Send">
|
||||||
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type VoiceInputState = "idle" | "recording" | "transcribing";
|
||||||
|
|
||||||
|
function pickRecorderFormat(): { mimeType: string; extension: string } {
|
||||||
|
const candidates = [
|
||||||
|
{ mimeType: "audio/wav", extension: "wav" },
|
||||||
|
{ mimeType: "audio/webm;codecs=opus", extension: "webm" },
|
||||||
|
{ mimeType: "audio/webm", extension: "webm" },
|
||||||
|
{ mimeType: "audio/mp4", extension: "m4a" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(candidate.mimeType)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mimeType: "", extension: "webm" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoiceInput(options: {
|
||||||
|
onTranscript: (text: string) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [state, setState] = useState<VoiceInputState>("idle");
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const formatRef = useRef(pickRecorderFormat());
|
||||||
|
const onTranscriptRef = useRef(options.onTranscript);
|
||||||
|
const onErrorRef = useRef(options.onError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onTranscriptRef.current = options.onTranscript;
|
||||||
|
onErrorRef.current = options.onError;
|
||||||
|
}, [options.onTranscript, options.onError]);
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
|
track.stop();
|
||||||
|
}
|
||||||
|
streamRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopRecording = useCallback(async () => {
|
||||||
|
const recorder = recorderRef.current;
|
||||||
|
if (!recorder || recorder.state === "inactive") return;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
recorder.addEventListener("stop", () => resolve(), { once: true });
|
||||||
|
recorder.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
recorderRef.current = null;
|
||||||
|
stopStream();
|
||||||
|
|
||||||
|
const blob = new Blob(chunksRef.current, {
|
||||||
|
type: formatRef.current.mimeType || chunksRef.current[0]?.type || "audio/webm",
|
||||||
|
});
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
if (blob.size === 0) {
|
||||||
|
setState("idle");
|
||||||
|
onErrorRef.current("No audio captured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState("transcribing");
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", blob, `recording.${formatRef.current.extension}`);
|
||||||
|
const response = await fetch("/api/agent/transcribe", { method: "POST", body: formData });
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
throw new Error(payload?.error ?? "Transcription failed");
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as { text: string };
|
||||||
|
onTranscriptRef.current(payload.text);
|
||||||
|
} catch (err) {
|
||||||
|
onErrorRef.current(err instanceof Error ? err.message : "Transcription failed");
|
||||||
|
} finally {
|
||||||
|
setState("idle");
|
||||||
|
}
|
||||||
|
}, [stopStream]);
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
if (options.disabled || state !== "idle") return;
|
||||||
|
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
|
||||||
|
onErrorRef.current("Microphone not available in this browser");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatRef.current = pickRecorderFormat();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
const recorder = formatRef.current.mimeType
|
||||||
|
? new MediaRecorder(stream, { mimeType: formatRef.current.mimeType })
|
||||||
|
: new MediaRecorder(stream);
|
||||||
|
chunksRef.current = [];
|
||||||
|
recorder.addEventListener("dataavailable", (event) => {
|
||||||
|
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||||
|
});
|
||||||
|
recorder.start();
|
||||||
|
recorderRef.current = recorder;
|
||||||
|
setState("recording");
|
||||||
|
} catch {
|
||||||
|
stopStream();
|
||||||
|
onErrorRef.current("Microphone permission denied");
|
||||||
|
}
|
||||||
|
}, [options.disabled, state, stopStream]);
|
||||||
|
|
||||||
|
const toggleRecording = useCallback(async () => {
|
||||||
|
if (state === "recording") {
|
||||||
|
await stopRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state === "idle") {
|
||||||
|
await startRecording();
|
||||||
|
}
|
||||||
|
}, [startRecording, state, stopRecording]);
|
||||||
|
|
||||||
|
return { state, toggleRecording };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { isValidLlmModelId } from "@/lib/llm/models";
|
||||||
|
|
||||||
|
export const clientChatAttachmentSchema = z.object({
|
||||||
|
type: z.literal("image"),
|
||||||
|
url: z.string().trim().min(1).max(2048),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientChatMessageSchema = z.object({
|
||||||
|
role: z.enum(["user", "assistant"]),
|
||||||
|
content: z.string().trim().min(1).max(8000),
|
||||||
|
attachments: z.array(clientChatAttachmentSchema).max(3).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientChatModelSchema = z
|
||||||
|
.string()
|
||||||
|
.refine((value) => isValidLlmModelId(value), "Invalid assistant model");
|
||||||
|
|
||||||
|
export const clientChatInputSchema = z.object({
|
||||||
|
stream: z.boolean().optional(),
|
||||||
|
model: clientChatModelSchema.optional(),
|
||||||
|
messages: z.array(clientChatMessageSchema).min(1).max(40),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ClientChatAttachment = z.infer<typeof clientChatAttachmentSchema>;
|
||||||
|
export type ClientChatMessage = z.infer<typeof clientChatMessageSchema>;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const MUTATION_TOOLS = new Set([
|
||||||
|
"add_list_item",
|
||||||
|
"update_list_item",
|
||||||
|
"delete_list_item",
|
||||||
|
"create_list",
|
||||||
|
"update_list",
|
||||||
|
"delete_list",
|
||||||
|
"create_event",
|
||||||
|
"update_event",
|
||||||
|
"delete_event",
|
||||||
|
"create_calendar",
|
||||||
|
"create_note",
|
||||||
|
"update_note",
|
||||||
|
"delete_note",
|
||||||
|
"create_journal_entry",
|
||||||
|
"update_journal_entry",
|
||||||
|
"delete_journal_entry",
|
||||||
|
"create_garden_plant",
|
||||||
|
"update_garden_plant",
|
||||||
|
"delete_garden_plant",
|
||||||
|
"create_garden_container",
|
||||||
|
"update_garden_container",
|
||||||
|
"delete_garden_container",
|
||||||
|
"log_garden_care",
|
||||||
|
"create_garden_care_schedule",
|
||||||
|
"update_garden_care_schedule",
|
||||||
|
"delete_garden_care_schedule",
|
||||||
|
"schedule_garden_care_on_calendar",
|
||||||
|
"create_bang",
|
||||||
|
"update_bang",
|
||||||
|
"delete_bang",
|
||||||
|
"create_share_link",
|
||||||
|
"revoke_share_link",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isMutationTool(name: string): boolean {
|
||||||
|
return MUTATION_TOOLS.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuccessfulWrite(name: string, status: number, argsJson = ""): boolean {
|
||||||
|
if (!isSuccessfulStatus(status)) return false;
|
||||||
|
if (isMutationTool(name)) return true;
|
||||||
|
if (name !== "call_api") return false;
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(argsJson || "{}") as { method?: string };
|
||||||
|
const method = typeof args.method === "string" ? args.method.toUpperCase() : "";
|
||||||
|
return method === "POST" || method === "PATCH" || method === "DELETE";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuccessfulStatus(status: number): boolean {
|
||||||
|
return status >= 200 && status < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fingerprintToolCalls(calls: Array<{ name: string; arguments: string }>): string {
|
||||||
|
return calls.map((call) => `${call.name}:${normalizeArgs(call.arguments)}`).join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateToolResult(result: string, maxChars = 6000): string {
|
||||||
|
if (result.length <= maxChars) return result;
|
||||||
|
return `${result.slice(0, maxChars)}\n…[truncated ${result.length - maxChars} chars]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeToolTrace(toolCalls: Array<{ name: string; status: number }>): string {
|
||||||
|
if (toolCalls.length === 0) return "No tools were called.";
|
||||||
|
return toolCalls.map((call) => `${call.name}→${call.status}`).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArgs(argsJson: string): string {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(argsJson || "{}");
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return JSON.stringify(parsed);
|
||||||
|
}
|
||||||
|
const sorted: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(parsed as Record<string, unknown>).sort()) {
|
||||||
|
sorted[key] = (parsed as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return JSON.stringify(sorted);
|
||||||
|
} catch {
|
||||||
|
return argsJson.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { minioClient, MINIO_BUCKET } from "@/lib/minio";
|
||||||
|
|
||||||
|
const UPLOAD_PATH_PREFIX = "/api/uploads/";
|
||||||
|
|
||||||
|
function uploadKeyFromUrl(url: string): string | null {
|
||||||
|
if (!url.startsWith(UPLOAD_PATH_PREFIX)) return null;
|
||||||
|
const key = url.slice(UPLOAD_PATH_PREFIX.length);
|
||||||
|
if (!key || key.includes("..")) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAssistantImageDataUrl(url: string): Promise<string> {
|
||||||
|
const key = uploadKeyFromUrl(url);
|
||||||
|
if (!key) {
|
||||||
|
throw new Error("Unsupported image URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await minioClient.getObject(MINIO_BUCKET, key);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
const stat = await minioClient.statObject(MINIO_BUCKET, key);
|
||||||
|
const metaData = stat.metaData as Record<string, string> | undefined;
|
||||||
|
const contentType =
|
||||||
|
metaData?.["content-type"] ?? metaData?.["Content-Type"] ?? "application/octet-stream";
|
||||||
|
|
||||||
|
return `data:${contentType};base64,${buffer.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAssistantImageDataUrls(urls: string[]): Promise<string[]> {
|
||||||
|
return Promise.all(urls.map((url) => resolveAssistantImageDataUrl(url)));
|
||||||
|
}
|
||||||
+165
-22
@@ -1,15 +1,20 @@
|
|||||||
|
import logger from "@/lib/logger";
|
||||||
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
|
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
|
||||||
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools";
|
import { buildVisionContentParts, textFromMessageContent } from "@/lib/llm/content";
|
||||||
|
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS, appendAgentRuntimeContext } from "../tools";
|
||||||
|
import type { ClientChatMessage } from "../messages";
|
||||||
import { describeToolActivity } from "../tool-labels";
|
import { describeToolActivity } from "../tool-labels";
|
||||||
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
|
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
|
||||||
|
import {
|
||||||
|
fingerprintToolCalls,
|
||||||
|
isSuccessfulWrite,
|
||||||
|
summarizeToolTrace,
|
||||||
|
truncateToolResult,
|
||||||
|
} from "./loop-guards";
|
||||||
|
import { resolveAssistantImageDataUrls } from "./resolve-images";
|
||||||
import { thinkingLabel, type AgentProgressEvent } from "./progress";
|
import { thinkingLabel, type AgentProgressEvent } from "./progress";
|
||||||
|
|
||||||
const MAX_TOOL_ROUNDS = 8;
|
const MAX_TOOL_ROUNDS = 24;
|
||||||
|
|
||||||
export type ClientChatMessage = {
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentToolCallSummary = {
|
export type AgentToolCallSummary = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,35 +28,72 @@ export type AgentChatResult = {
|
|||||||
|
|
||||||
export type AgentProgressHandler = (event: AgentProgressEvent) => void;
|
export type AgentProgressHandler = (event: AgentProgressEvent) => void;
|
||||||
|
|
||||||
|
async function toLlmUserMessage(message: ClientChatMessage): Promise<ChatMessage> {
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
return { role: "assistant", content: message.content };
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrls =
|
||||||
|
message.attachments?.filter((attachment) => attachment.type === "image").map((a) => a.url) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
if (imageUrls.length === 0) {
|
||||||
|
return { role: "user", content: message.content };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrls = await resolveAssistantImageDataUrls(imageUrls);
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: buildVisionContentParts(message.content, dataUrls),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceReplyAfterWrite(writeNames: string[]): ChatMessage {
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: `The write already succeeded (${writeNames.join(", ")}). Stop calling tools and reply to the user in one short sentence confirming what you did.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceReplyAfterRepeat(): ChatMessage {
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content:
|
||||||
|
"You repeated the same tool call. Stop calling tools and reply with what you already know, or ask one clarifying question.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function runAgentChat(options: {
|
export async function runAgentChat(options: {
|
||||||
messages: ClientChatMessage[];
|
messages: ClientChatMessage[];
|
||||||
request: Request;
|
request: Request;
|
||||||
|
systemPrompt?: string;
|
||||||
|
model?: string;
|
||||||
llm?: LlmClient;
|
llm?: LlmClient;
|
||||||
executeTool?: ToolExecutor;
|
executeTool?: ToolExecutor;
|
||||||
onProgress?: AgentProgressHandler;
|
onProgress?: AgentProgressHandler;
|
||||||
}): Promise<AgentChatResult> {
|
}): Promise<AgentChatResult> {
|
||||||
const llm = options.llm ?? createLlmClient();
|
const llm = options.llm ?? createLlmClient({ model: options.model });
|
||||||
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
|
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
|
||||||
const onProgress = options.onProgress;
|
const onProgress = options.onProgress;
|
||||||
|
const systemPrompt = appendAgentRuntimeContext(options.systemPrompt ?? AGENT_SYSTEM_PROMPT);
|
||||||
|
|
||||||
const transcript: ChatMessage[] = [
|
const userMessages = await Promise.all(
|
||||||
{ role: "system", content: AGENT_SYSTEM_PROMPT },
|
options.messages.map((message) => toLlmUserMessage(message)),
|
||||||
...options.messages.map(
|
);
|
||||||
(message): ChatMessage => ({
|
|
||||||
role: message.role,
|
const transcript: ChatMessage[] = [{ role: "system", content: systemPrompt }, ...userMessages];
|
||||||
content: message.content,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const toolCalls: AgentToolCallSummary[] = [];
|
const toolCalls: AgentToolCallSummary[] = [];
|
||||||
|
const seenFingerprints = new Set<string>();
|
||||||
|
const successfulWriteNames: string[] = [];
|
||||||
|
let forceReply = false;
|
||||||
|
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
onProgress?.({ type: "thinking", label: thinkingLabel(round), round });
|
onProgress?.({ type: "thinking", label: thinkingLabel(round), round });
|
||||||
|
|
||||||
const completion = await llm.chatCompletion({
|
const completion = await llm.chatCompletion({
|
||||||
messages: transcript,
|
messages: transcript,
|
||||||
tools: AGENT_TOOLS,
|
tools: forceReply ? undefined : AGENT_TOOLS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const assistantMessage = completion.message;
|
const assistantMessage = completion.message;
|
||||||
@@ -59,15 +101,56 @@ export async function runAgentChat(options: {
|
|||||||
|
|
||||||
if (!assistantMessage.tool_calls?.length) {
|
if (!assistantMessage.tool_calls?.length) {
|
||||||
onProgress?.({ type: "responding", label: "Writing a reply…" });
|
onProgress?.({ type: "responding", label: "Writing a reply…" });
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
msg: "agent.chat.done",
|
||||||
|
rounds: round + 1,
|
||||||
|
toolCalls: toolCalls.map((call) => `${call.name}:${call.status}`),
|
||||||
|
forcedReply: forceReply,
|
||||||
|
},
|
||||||
|
"agent chat completed",
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: assistantMessage.content?.trim() || "I couldn't generate a response.",
|
content:
|
||||||
|
textFromMessageContent(assistantMessage.content).trim() ||
|
||||||
|
"I couldn't generate a response.",
|
||||||
},
|
},
|
||||||
toolCalls,
|
toolCalls,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (forceReply) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
msg: "agent.chat.forced_tools_ignored",
|
||||||
|
round,
|
||||||
|
names: assistantMessage.tool_calls.map((c) => c.function.name),
|
||||||
|
},
|
||||||
|
"model kept calling tools after force-reply",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = fingerprintToolCalls(
|
||||||
|
assistantMessage.tool_calls.map((call) => ({
|
||||||
|
name: call.function.name,
|
||||||
|
arguments: call.function.arguments,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seenFingerprints.has(fingerprint)) {
|
||||||
|
logger.warn(
|
||||||
|
{ msg: "agent.chat.duplicate_tools", round, fingerprint, toolCalls: fingerprint },
|
||||||
|
"duplicate tool round detected",
|
||||||
|
);
|
||||||
|
transcript.push(forceReplyAfterRepeat());
|
||||||
|
forceReply = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenFingerprints.add(fingerprint);
|
||||||
|
|
||||||
for (const toolCall of assistantMessage.tool_calls) {
|
for (const toolCall of assistantMessage.tool_calls) {
|
||||||
const label = describeToolActivity(toolCall.function.name, toolCall.function.arguments);
|
const label = describeToolActivity(toolCall.function.name, toolCall.function.arguments);
|
||||||
onProgress?.({ type: "tool", name: toolCall.function.name, label });
|
onProgress?.({ type: "tool", name: toolCall.function.name, label });
|
||||||
@@ -77,8 +160,20 @@ export async function runAgentChat(options: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
result = await executeTool(toolCall.function.name, toolCall.function.arguments);
|
result = await executeTool(toolCall.function.name, toolCall.function.arguments);
|
||||||
const parsed = JSON.parse(result) as { status?: number };
|
const parsed = JSON.parse(result) as { status?: number; body?: unknown };
|
||||||
status = typeof parsed.status === "number" ? parsed.status : 200;
|
status = typeof parsed.status === "number" ? parsed.status : 200;
|
||||||
|
if (status >= 400) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
msg: "agent.chat.tool_error",
|
||||||
|
round,
|
||||||
|
name: toolCall.function.name,
|
||||||
|
status,
|
||||||
|
body: parsed.body,
|
||||||
|
},
|
||||||
|
"agent tool returned error",
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status = 500;
|
status = 500;
|
||||||
result = JSON.stringify({
|
result = JSON.stringify({
|
||||||
@@ -88,21 +183,69 @@ export async function runAgentChat(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toolCalls.push({ name: toolCall.function.name, status });
|
toolCalls.push({ name: toolCall.function.name, status });
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
msg: "agent.chat.tool",
|
||||||
|
round,
|
||||||
|
name: toolCall.function.name,
|
||||||
|
status,
|
||||||
|
args: toolCall.function.arguments.slice(0, 300),
|
||||||
|
},
|
||||||
|
"agent tool call",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSuccessfulWrite(toolCall.function.name, status, toolCall.function.arguments)) {
|
||||||
|
successfulWriteNames.push(toolCall.function.name);
|
||||||
|
}
|
||||||
|
|
||||||
transcript.push({
|
transcript.push({
|
||||||
role: "tool",
|
role: "tool",
|
||||||
tool_call_id: toolCall.id,
|
tool_call_id: toolCall.id,
|
||||||
name: toolCall.function.name,
|
name: toolCall.function.name,
|
||||||
content: result,
|
content: truncateToolResult(result),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (successfulWriteNames.length > 0) {
|
||||||
|
transcript.push(forceReplyAfterWrite(successfulWriteNames));
|
||||||
|
forceReply = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress?.({ type: "responding", label: "Wrapping up…" });
|
onProgress?.({ type: "responding", label: "Wrapping up…" });
|
||||||
|
const trace = summarizeToolTrace(toolCalls);
|
||||||
|
|
||||||
|
if (successfulWriteNames.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
msg: "agent.chat.forced_summary",
|
||||||
|
rounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: toolCalls.map((call) => `${call.name}:${call.status}`),
|
||||||
|
},
|
||||||
|
"agent summarizing after write without clean stop",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: `Done. Completed: ${[...new Set(successfulWriteNames)].join(", ")}.`,
|
||||||
|
},
|
||||||
|
toolCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
msg: "agent.chat.tool_limit",
|
||||||
|
rounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: toolCalls.map((call) => `${call.name}:${call.status}`),
|
||||||
|
forcedReply: forceReply,
|
||||||
|
},
|
||||||
|
"agent hit tool-call limit",
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "I hit the tool-call limit for this request. Please try a simpler question.",
|
content: `I hit the tool-call limit for this request. Tools used: ${trace}.`,
|
||||||
},
|
},
|
||||||
toolCalls,
|
toolCalls,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
type ApiCallResult = {
|
type ApiCallResult = {
|
||||||
status: number;
|
status: number;
|
||||||
body: unknown;
|
body: unknown;
|
||||||
@@ -5,8 +7,22 @@ type ApiCallResult = {
|
|||||||
|
|
||||||
export type ToolExecutor = (name: string, argsJson: string) => Promise<string>;
|
export type ToolExecutor = (name: string, argsJson: string) => Promise<string>;
|
||||||
|
|
||||||
|
/** Loopback base for in-process tool → /api/v1 calls. Avoids hairpinning to the public URL. */
|
||||||
|
export function resolveInternalApiBase(request: Request): string {
|
||||||
|
const configured = process.env.INTERNAL_API_BASE_URL?.trim();
|
||||||
|
if (configured) return configured.replace(/\/$/, "");
|
||||||
|
|
||||||
|
const port = process.env.PORT?.trim() || "3000";
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
if (requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1") {
|
||||||
|
return requestUrl.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `http://127.0.0.1:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createApiToolExecutor(request: Request): ToolExecutor {
|
export function createApiToolExecutor(request: Request): ToolExecutor {
|
||||||
const origin = new URL(request.url).origin;
|
const origin = resolveInternalApiBase(request);
|
||||||
|
|
||||||
return async (name: string, argsJson: string) => {
|
return async (name: string, argsJson: string) => {
|
||||||
const args = parseArgs(argsJson);
|
const args = parseArgs(argsJson);
|
||||||
@@ -111,8 +127,11 @@ async function dispatchTool(
|
|||||||
return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`);
|
return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`);
|
||||||
}
|
}
|
||||||
case "create_event": {
|
case "create_event": {
|
||||||
|
const resolved = await resolveCalendarId(args, request, origin);
|
||||||
|
if ("error" in resolved) return resolved.error;
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
calendarId: requireString(args, "calendarId"),
|
calendarId: resolved.calendarId,
|
||||||
title: requireString(args, "title"),
|
title: requireString(args, "title"),
|
||||||
startAt: requireString(args, "startAt"),
|
startAt: requireString(args, "startAt"),
|
||||||
endAt: requireString(args, "endAt"),
|
endAt: requireString(args, "endAt"),
|
||||||
@@ -375,6 +394,49 @@ async function dispatchTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveCalendarId(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
request: Request,
|
||||||
|
origin: string,
|
||||||
|
): Promise<{ calendarId: string } | { error: ApiCallResult }> {
|
||||||
|
const calendarId = typeof args.calendarId === "string" ? args.calendarId.trim() : "";
|
||||||
|
if (calendarId) return { calendarId };
|
||||||
|
|
||||||
|
const calendarsResult = await callApi(request, origin, "GET", "/api/v1/calendars");
|
||||||
|
if (calendarsResult.status !== 200 || !Array.isArray(calendarsResult.body)) {
|
||||||
|
return { error: calendarsResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendars = calendarsResult.body as Array<{ id?: string; name?: string }>;
|
||||||
|
const calendarName =
|
||||||
|
typeof args.calendarName === "string" ? args.calendarName.trim().toLowerCase() : "";
|
||||||
|
|
||||||
|
if (calendarName) {
|
||||||
|
const match = calendars.find(
|
||||||
|
(calendar) =>
|
||||||
|
typeof calendar.name === "string" && calendar.name.toLowerCase() === calendarName,
|
||||||
|
);
|
||||||
|
if (!match?.id) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
error: `No calendar named "${args.calendarName}"`,
|
||||||
|
calendars: calendars.map((calendar) => ({ id: calendar.id, name: calendar.name })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { calendarId: match.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = calendars[0];
|
||||||
|
if (!first?.id) {
|
||||||
|
return { error: { status: 404, body: { error: "No calendars found" } } };
|
||||||
|
}
|
||||||
|
return { calendarId: first.id };
|
||||||
|
}
|
||||||
|
|
||||||
function requireString(args: Record<string, unknown>, key: string): string {
|
function requireString(args: Record<string, unknown>, key: string): string {
|
||||||
const value = args[key];
|
const value = args[key];
|
||||||
if (typeof value !== "string" || value.trim().length === 0) {
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
@@ -405,13 +467,18 @@ async function getApiDocs(args: Record<string, unknown>): Promise<ApiCallResult>
|
|||||||
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
|
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
|
||||||
const spec = await readFile(specPath, "utf8");
|
const spec = await readFile(specPath, "utf8");
|
||||||
const search = typeof args.search === "string" ? args.search.trim().toLowerCase() : "";
|
const search = typeof args.search === "string" ? args.search.trim().toLowerCase() : "";
|
||||||
|
const pathLines = spec
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.startsWith("/api/v1/"));
|
||||||
|
|
||||||
if (!search) {
|
if (!search) {
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
spec,
|
paths: pathLines.slice(0, 80),
|
||||||
hint: "Pass search to filter paths, or use call_api with a /api/v1/* path.",
|
pathCount: pathLines.length,
|
||||||
|
hint: "Pass search (e.g. calendar, events, lists) to get matching lines. Do not request the full OpenAPI dump.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -423,8 +490,8 @@ async function getApiDocs(args: Record<string, unknown>): Promise<ApiCallResult>
|
|||||||
body: {
|
body: {
|
||||||
search,
|
search,
|
||||||
matchCount: matches.length,
|
matchCount: matches.length,
|
||||||
matches: matches.slice(0, 100),
|
matches: matches.slice(0, 40),
|
||||||
hint: "Use call_api with method and path from the matches above.",
|
hint: "Use a dedicated tool when one exists; otherwise call_api with method and path from the matches above.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -471,14 +538,30 @@ async function callApi(
|
|||||||
path: string,
|
path: string,
|
||||||
body?: Record<string, unknown>,
|
body?: Record<string, unknown>,
|
||||||
): Promise<ApiCallResult> {
|
): Promise<ApiCallResult> {
|
||||||
const response = await fetch(`${origin}${path}`, {
|
const url = `${origin}${path}`;
|
||||||
method,
|
const headers: Record<string, string> = {
|
||||||
headers: {
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
};
|
||||||
cookie: request.headers.get("cookie") ?? "",
|
const cookie = request.headers.get("cookie");
|
||||||
},
|
if (cookie) headers.cookie = cookie;
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
const authorization = request.headers.get("authorization");
|
||||||
});
|
if (authorization) headers.authorization = authorization;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "fetch failed";
|
||||||
|
logger.error(
|
||||||
|
{ msg: "agent.tool.fetch_failed", method, url, error: message },
|
||||||
|
"agent tool internal fetch failed",
|
||||||
|
);
|
||||||
|
return { status: 502, body: { error: `Internal API unreachable: ${message}`, url } };
|
||||||
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
let parsed: unknown = null;
|
let parsed: unknown = null;
|
||||||
@@ -490,5 +573,12 @@ async function callApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
|
logger.warn(
|
||||||
|
{ msg: "agent.tool.api_error", method, url, status: response.status, body: parsed },
|
||||||
|
"agent tool API error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { status: response.status, body: parsed };
|
return { status: response.status, body: parsed };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,11 +159,16 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
|
|||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: "create_event",
|
name: "create_event",
|
||||||
description: "Create a calendar event.",
|
description:
|
||||||
|
"Create a calendar event. Provide calendarId, or calendarName to match by name, or omit both to use the first visible calendar.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
calendarId: { type: "string" },
|
calendarId: { type: "string", description: "UUID of the calendar" },
|
||||||
|
calendarName: {
|
||||||
|
type: "string",
|
||||||
|
description: "Calendar display name when calendarId is unknown",
|
||||||
|
},
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
startAt: { type: "string", description: "ISO 8601 start" },
|
startAt: { type: "string", description: "ISO 8601 start" },
|
||||||
endAt: { type: "string", description: "ISO 8601 end" },
|
endAt: { type: "string", description: "ISO 8601 end" },
|
||||||
@@ -175,7 +180,7 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
|
|||||||
description: "Optional reminder N minutes before start",
|
description: "Optional reminder N minutes before start",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ["calendarId", "title", "startAt", "endAt"],
|
required: ["title", "startAt", "endAt"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -713,15 +718,16 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
|
|||||||
function: {
|
function: {
|
||||||
name: "get_api_docs",
|
name: "get_api_docs",
|
||||||
description:
|
description:
|
||||||
"Read famapp REST API documentation (OpenAPI). Use when unsure which endpoint to call or no dedicated tool exists. Pass search to filter relevant paths.",
|
"Search famapp REST API docs for /api/v1 paths. Always pass search. Returns matching lines only — not the full OpenAPI file.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
search: {
|
search: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Optional keyword to filter paths (e.g. garden, share, journal)",
|
description: "Keyword to filter paths (e.g. garden, share, journal, events)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
required: ["search"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -762,11 +768,15 @@ export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help
|
|||||||
|
|
||||||
Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly.
|
Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly.
|
||||||
|
|
||||||
When no dedicated tool fits, or you are unsure how to do something:
|
Prefer a dedicated tool when one exists (create_event, add_list_item, create_note, etc.). After a successful write (2xx), stop calling tools and confirm in one short sentence — do not re-list or re-create.
|
||||||
1. Call get_api_docs with a relevant search term to find the right /api/v1/* endpoint.
|
When no dedicated tool fits, or you need an endpoint that is not wrapped yet:
|
||||||
|
1. Call get_api_docs with a relevant search term (always pass search; never request the full spec).
|
||||||
2. Call call_api with the documented method, path, query, and body.
|
2. Call call_api with the documented method, path, query, and body.
|
||||||
|
If a tool call fails, read the error body and fix the arguments before trying a different approach. Do not repeat the exact same tool call.
|
||||||
|
|
||||||
Lists: resolve list ids via list_lists. To complete items, list_list_items then update_list_item with done: true.
|
When the user sends a photo, read dates, times, locations, and action items from it, then use tools to act.
|
||||||
|
|
||||||
|
Lists: resolve list ids via list_lists, or pass listType to add_list_item. To complete items, list_list_items then update_list_item with done: true.
|
||||||
|
|
||||||
Journal: per-user private entries. Valid mood ids: ${JOURNAL_MOOD_IDS}. stress is 1-10. pillsTaken is boolean.
|
Journal: per-user private entries. Valid mood ids: ${JOURNAL_MOOD_IDS}. stress is 1-10. pillsTaken is boolean.
|
||||||
|
|
||||||
@@ -774,4 +784,32 @@ Garden: care types are free text (water, fertilize, prune, etc.). Use list_garde
|
|||||||
|
|
||||||
Sharing: journal entries are not shareable. Shareable types: calendar, calendar.event, list, note, garden.plant, garden.container.
|
Sharing: journal entries are not shareable. Shareable types: calendar, calendar.event, list, note, garden.plant, garden.container.
|
||||||
|
|
||||||
Calendar: use ISO 8601 datetimes. Bang dates use YYYY-MM-DD.`;
|
Calendar: use ISO 8601 datetimes with the household timezone below. Pass calendarId, or calendarName, or omit both to use the first visible calendar. Bang dates use YYYY-MM-DD.`;
|
||||||
|
|
||||||
|
export function resolveHouseholdTimezone(): string {
|
||||||
|
return process.env.HOUSEHOLD_TIMEZONE?.trim() || process.env.TZ?.trim() || "America/Chicago";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendAgentRuntimeContext(prompt: string, now: Date = new Date()): string {
|
||||||
|
const timeZone = resolveHouseholdTimezone();
|
||||||
|
let localNow: string;
|
||||||
|
try {
|
||||||
|
localNow = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
timeZoneName: "short",
|
||||||
|
}).format(now);
|
||||||
|
} catch {
|
||||||
|
localNow = now.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prompt.trim()}
|
||||||
|
|
||||||
|
Current time: ${localNow} (${timeZone}). ISO now: ${now.toISOString()}. Resolve relative dates from this clock.`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { BangAggregatesDto } from "../server/queries";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
aggregates: BangAggregatesDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BangStatsWidget({ aggregates }: Props) {
|
||||||
|
const recentMonths = aggregates.monthlyCounts.slice(-6);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<StatBlock label="This month" value={String(aggregates.thisMonth)} />
|
||||||
|
<StatBlock label="This year" value={String(aggregates.thisYear)} />
|
||||||
|
<StatBlock
|
||||||
|
label="Avg gap"
|
||||||
|
value={aggregates.averageDaysBetween == null ? "—" : `${aggregates.averageDaysBetween}d`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentMonths.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-[var(--ink-mute)]">Recent months</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{recentMonths.map((row) => (
|
||||||
|
<div key={row.month} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{formatMonth(row.month)}</span>
|
||||||
|
<span className="tabular-nums font-medium">{row.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--ink-mute)]">No bangs recorded yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatBlock({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--hair)] px-3 py-2">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-[var(--ink-mute)]">{label}</div>
|
||||||
|
<div className="text-2xl font-semibold tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(monthKey: string): string {
|
||||||
|
const [year, month] = monthKey.split("-").map(Number);
|
||||||
|
return new Date(year!, month! - 1, 1).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||||
import { getBangStats } from "./server/queries";
|
import { getBangAggregates, getBangStats } from "./server/queries";
|
||||||
import { BangWidget } from "./components/bang-widget";
|
import { BangWidget } from "./components/bang-widget";
|
||||||
|
import { BangStatsWidget } from "./components/bang-stats-widget";
|
||||||
|
|
||||||
const bangWidgetConfigSchema = z.object({
|
const bangWidgetConfigSchema = z.object({
|
||||||
maxRecentBangs: z.number().int().min(1).max(20).default(5),
|
maxRecentBangs: z.number().int().min(1).max(20).default(5),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bangStatsConfigSchema = z.object({});
|
||||||
|
|
||||||
async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
|
async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
|
||||||
const parsed = bangWidgetConfigSchema.parse(config);
|
const parsed = bangWidgetConfigSchema.parse(config);
|
||||||
const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs);
|
const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs);
|
||||||
return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />;
|
return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function BangStatsWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
|
||||||
|
bangStatsConfigSchema.parse(config);
|
||||||
|
const aggregates = await getBangAggregates(ctx.householdId);
|
||||||
|
return <BangStatsWidget aggregates={aggregates} />;
|
||||||
|
}
|
||||||
|
|
||||||
const bangsManifest: ModuleManifest = {
|
const bangsManifest: ModuleManifest = {
|
||||||
id: "bangs",
|
id: "bangs",
|
||||||
name: "Bangs",
|
name: "Bangs",
|
||||||
@@ -44,6 +53,19 @@ const bangsManifest: ModuleManifest = {
|
|||||||
defaultConfig: { maxRecentBangs: 5 },
|
defaultConfig: { maxRecentBangs: 5 },
|
||||||
render: (props) => <BangWidgetServer {...props} />,
|
render: (props) => <BangWidgetServer {...props} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "bangs.stats",
|
||||||
|
title: "Bang Stats",
|
||||||
|
description: "Monthly and yearly bang counts plus average days between bangs.",
|
||||||
|
category: "Fun",
|
||||||
|
defaultSize: { w: 3, h: 3 },
|
||||||
|
minSize: { w: 2, h: 2 },
|
||||||
|
defaultPriority: 55,
|
||||||
|
configSchema: bangStatsConfigSchema,
|
||||||
|
defaultConfig: {},
|
||||||
|
resolveConfigOptions: async () => undefined,
|
||||||
|
render: (props) => <BangStatsWidgetServer {...props} />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
quickAdds: [
|
quickAdds: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { count, desc, eq } from "drizzle-orm";
|
import { asc, count, desc, eq, sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { users } from "@/modules/_core/schema";
|
import { users } from "@/modules/_core/schema";
|
||||||
import { bangEvents } from "../schema";
|
import { bangEvents } from "../schema";
|
||||||
@@ -14,6 +14,13 @@ export type RecentBangDto = {
|
|||||||
recordedByName: string | null;
|
recordedByName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BangAggregatesDto = {
|
||||||
|
thisMonth: number;
|
||||||
|
thisYear: number;
|
||||||
|
averageDaysBetween: number | null;
|
||||||
|
monthlyCounts: { month: string; count: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function getBangStatsForScope(
|
export async function getBangStatsForScope(
|
||||||
householdId: string,
|
householdId: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
@@ -48,3 +55,61 @@ export async function getBangStatsForScope(
|
|||||||
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
|
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
|
||||||
return getBangStatsForScope(householdId, limit);
|
return getBangStatsForScope(householdId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBangAggregates(householdId: string): Promise<BangAggregatesDto> {
|
||||||
|
const now = new Date();
|
||||||
|
const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const yearPrefix = `${now.getFullYear()}-`;
|
||||||
|
|
||||||
|
const [thisMonthRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(
|
||||||
|
sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${monthPrefix}%`}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [thisYearRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(
|
||||||
|
sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${yearPrefix}%`}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthlyRows = await db
|
||||||
|
.select({
|
||||||
|
month: sql<string>`substring(${bangEvents.occurredOn}, 1, 7)`,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(eq(bangEvents.householdId, householdId))
|
||||||
|
.groupBy(sql`substring(${bangEvents.occurredOn}, 1, 7)`)
|
||||||
|
.orderBy(asc(sql`substring(${bangEvents.occurredOn}, 1, 7)`));
|
||||||
|
|
||||||
|
const dateRows = await db
|
||||||
|
.select({ occurredOn: bangEvents.occurredOn })
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(eq(bangEvents.householdId, householdId))
|
||||||
|
.orderBy(asc(bangEvents.occurredOn));
|
||||||
|
|
||||||
|
let averageDaysBetween: number | null = null;
|
||||||
|
if (dateRows.length >= 2) {
|
||||||
|
const dates = dateRows.map((row) => parseBangDate(row.occurredOn).getTime());
|
||||||
|
let totalGapDays = 0;
|
||||||
|
for (let i = 1; i < dates.length; i++) {
|
||||||
|
totalGapDays += (dates[i]! - dates[i - 1]!) / (1000 * 60 * 60 * 24);
|
||||||
|
}
|
||||||
|
averageDaysBetween = Math.round((totalGapDays / (dates.length - 1)) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thisMonth: thisMonthRow?.count ?? 0,
|
||||||
|
thisYear: thisYearRow?.count ?? 0,
|
||||||
|
averageDaysBetween,
|
||||||
|
monthlyCounts: monthlyRows.map((row) => ({ month: row.month, count: row.count })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBangDate(isoDate: string): Date {
|
||||||
|
const [year, month, day] = isoDate.split("-").map(Number);
|
||||||
|
return new Date(year!, month! - 1, day!);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ShareButton } from "@/components/share-button";
|
import { ShareButton } from "@/components/share-button";
|
||||||
|
import { ReminderPicker } from "@/components/reminder-picker";
|
||||||
import type { CalView } from "@/modules/_core/themes";
|
import type { CalView } from "@/modules/_core/themes";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import type { CalendarDto, CalendarEventDto } from "../server/queries";
|
import type { CalendarDto, CalendarEventDto } from "../server/queries";
|
||||||
|
import { getEventReminderOffsets } from "../server/queries";
|
||||||
import {
|
import {
|
||||||
createCalendar,
|
createCalendar,
|
||||||
createEvent,
|
createEvent,
|
||||||
@@ -57,7 +59,7 @@ type EventDraft = {
|
|||||||
allDay: boolean;
|
allDay: boolean;
|
||||||
location: string;
|
location: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
remindMinutesBefore: number | null;
|
reminderOffsets: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type MobileView = "day" | "week" | "agenda";
|
type MobileView = "day" | "week" | "agenda";
|
||||||
@@ -99,10 +101,12 @@ export function CalendarShell({
|
|||||||
calendars,
|
calendars,
|
||||||
events,
|
events,
|
||||||
defaultView = "month",
|
defaultView = "month",
|
||||||
|
defaultReminderOffsets = [30],
|
||||||
}: {
|
}: {
|
||||||
calendars: CalendarDto[];
|
calendars: CalendarDto[];
|
||||||
events: CalendarEventDto[];
|
events: CalendarEventDto[];
|
||||||
defaultView?: CalView;
|
defaultView?: CalView;
|
||||||
|
defaultReminderOffsets?: number[];
|
||||||
}) {
|
}) {
|
||||||
const [calendarRows, setCalendarRows] = useState(calendars);
|
const [calendarRows, setCalendarRows] = useState(calendars);
|
||||||
const [eventRows, setEventRows] = useState(events);
|
const [eventRows, setEventRows] = useState(events);
|
||||||
@@ -160,13 +164,14 @@ export function CalendarShell({
|
|||||||
allDay,
|
allDay,
|
||||||
location: "",
|
location: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
remindMinutesBefore: 30,
|
reminderOffsets: [...defaultReminderOffsets],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openExistingEvent({ event }: EventClickArg) {
|
function openExistingEvent({ event }: EventClickArg) {
|
||||||
const row = eventRows.find((item) => item.id === event.id);
|
const row = eventRows.find((item) => item.id === event.id);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
const eventId = row.id;
|
||||||
setSelectedEvent({
|
setSelectedEvent({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
calendarId: row.calendarId,
|
calendarId: row.calendarId,
|
||||||
@@ -176,7 +181,12 @@ export function CalendarShell({
|
|||||||
allDay: row.allDay,
|
allDay: row.allDay,
|
||||||
location: row.location ?? "",
|
location: row.location ?? "",
|
||||||
notes: row.notes ?? "",
|
notes: row.notes ?? "",
|
||||||
remindMinutesBefore: null,
|
reminderOffsets: [],
|
||||||
|
});
|
||||||
|
void getEventReminderOffsets(eventId).then((offsets) => {
|
||||||
|
setSelectedEvent((current) =>
|
||||||
|
current?.id === eventId ? { ...current, reminderOffsets: offsets } : current,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +209,11 @@ export function CalendarShell({
|
|||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
await updateEvent({ id: eventId, ...payload });
|
await updateEvent({
|
||||||
|
id: eventId,
|
||||||
|
...payload,
|
||||||
|
reminderOffsets: selectedEvent.reminderOffsets,
|
||||||
|
});
|
||||||
setLastCalendarId(payload.calendarId);
|
setLastCalendarId(payload.calendarId);
|
||||||
setEventRows((current) =>
|
setEventRows((current) =>
|
||||||
current.map((event) =>
|
current.map((event) =>
|
||||||
@@ -220,7 +234,7 @@ export function CalendarShell({
|
|||||||
} else {
|
} else {
|
||||||
const created = await createEvent({
|
const created = await createEvent({
|
||||||
...payload,
|
...payload,
|
||||||
remindMinutesBefore: selectedEvent.remindMinutesBefore,
|
reminderOffsets: selectedEvent.reminderOffsets,
|
||||||
});
|
});
|
||||||
setLastCalendarId(payload.calendarId);
|
setLastCalendarId(payload.calendarId);
|
||||||
setEventRows((current) => [...current, created]);
|
setEventRows((current) => [...current, created]);
|
||||||
@@ -538,22 +552,11 @@ export function CalendarShell({
|
|||||||
placeholder="Add details…"
|
placeholder="Add details…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!selectedEvent.id && (
|
<ReminderPicker
|
||||||
<label className="flex items-center gap-2 text-sm">
|
offsets={selectedEvent.reminderOffsets}
|
||||||
<input
|
onChange={(reminderOffsets) => setSelectedEvent({ ...selectedEvent, reminderOffsets })}
|
||||||
type="checkbox"
|
disabled={isPending}
|
||||||
className="size-4 cursor-pointer"
|
/>
|
||||||
checked={selectedEvent.remindMinutesBefore !== null}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedEvent({
|
|
||||||
...selectedEvent,
|
|
||||||
remindMinutesBefore: e.target.checked ? 30 : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
Remind me 30 min before
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between gap-2 pt-2">
|
<div className="flex items-center justify-between gap-2 pt-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{selectedEvent.id && (
|
{selectedEvent.id && (
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { db } from "@/lib/db";
|
|||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { logActivityForScope } from "@/modules/_core/activity";
|
import { logActivityForScope } from "@/modules/_core/activity";
|
||||||
import { householdMembers } from "@/modules/_core/schema";
|
import { householdMembers } from "@/modules/_core/schema";
|
||||||
import { scheduleReminder, cancelReminder } from "@/modules/_core/reminders";
|
import {
|
||||||
|
cancelReminder,
|
||||||
|
syncCalendarEventReminders,
|
||||||
|
listReminderOffsets,
|
||||||
|
} from "@/modules/_core/reminders";
|
||||||
|
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
|
||||||
import { calendarEvents, calendars } from "../schema";
|
import { calendarEvents, calendars } from "../schema";
|
||||||
import { canSeeCalendarForScope, type ApiScope, type CalendarEventDto } from "./queries";
|
import { canSeeCalendarForScope, type ApiScope, type CalendarEventDto } from "./queries";
|
||||||
import { calendarInput, calendarUpdateInput, eventInput, eventUpdateInput } from "./schemas";
|
import { calendarInput, calendarUpdateInput, eventInput, eventUpdateInput } from "./schemas";
|
||||||
@@ -193,17 +198,16 @@ export async function createEventForScope(
|
|||||||
|
|
||||||
if (!event) throw new Error("Event was not created");
|
if (!event) throw new Error("Event was not created");
|
||||||
|
|
||||||
if (parsed.remindMinutesBefore != null && scope.userId) {
|
const reminderOffsets = resolveReminderOffsets(parsed);
|
||||||
const fireAt = new Date(parsed.startAt.getTime() - parsed.remindMinutesBefore * 60_000);
|
if (reminderOffsets.length > 0 && scope.userId) {
|
||||||
if (fireAt > new Date()) {
|
await syncCalendarEventReminders({
|
||||||
await scheduleReminder({
|
householdId: scope.householdId,
|
||||||
householdId: scope.householdId,
|
eventId: event.id,
|
||||||
entityType: "calendar.event",
|
eventTitle: event.title,
|
||||||
entityId: event.id,
|
startAt: parsed.startAt,
|
||||||
fireAt,
|
createdBy: scope.userId,
|
||||||
createdBy: scope.userId,
|
offsetMinutes: reminderOffsets,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await logActivityForScope(scope, {
|
await logActivityForScope(scope, {
|
||||||
@@ -256,6 +260,43 @@ export async function updateEventForScope(
|
|||||||
})
|
})
|
||||||
.where(eq(calendarEvents.id, parsed.id));
|
.where(eq(calendarEvents.id, parsed.id));
|
||||||
|
|
||||||
|
if (scope.userId) {
|
||||||
|
const [updated] = await db
|
||||||
|
.select({ title: calendarEvents.title, startAt: calendarEvents.startAt })
|
||||||
|
.from(calendarEvents)
|
||||||
|
.where(eq(calendarEvents.id, parsed.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
const startAt = parsed.startAt ?? updated.startAt;
|
||||||
|
let offsets: number[];
|
||||||
|
|
||||||
|
if (parsed.reminderOffsets !== undefined || parsed.remindMinutesBefore !== undefined) {
|
||||||
|
offsets = resolveReminderOffsets(parsed);
|
||||||
|
} else if (parsed.startAt !== undefined) {
|
||||||
|
offsets = await listReminderOffsets("calendar.event", parsed.id);
|
||||||
|
} else {
|
||||||
|
offsets = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
offsets.length === 0 &&
|
||||||
|
(parsed.reminderOffsets !== undefined || parsed.remindMinutesBefore !== undefined)
|
||||||
|
) {
|
||||||
|
await cancelReminder("calendar.event", parsed.id);
|
||||||
|
} else if (offsets.length > 0) {
|
||||||
|
await syncCalendarEventReminders({
|
||||||
|
householdId: scope.householdId,
|
||||||
|
eventId: parsed.id,
|
||||||
|
eventTitle: updated.title,
|
||||||
|
startAt,
|
||||||
|
createdBy: scope.userId,
|
||||||
|
offsetMinutes: offsets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await logActivityForScope(scope, {
|
await logActivityForScope(scope, {
|
||||||
entityType: "calendar.event",
|
entityType: "calendar.event",
|
||||||
entityId: parsed.id,
|
entityId: parsed.id,
|
||||||
@@ -304,3 +345,16 @@ async function assertOwnsCalendar(userId: string, calendarId: string) {
|
|||||||
|
|
||||||
if (!calendar) throw new Error("Forbidden");
|
if (!calendar) throw new Error("Forbidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReminderOffsets(input: {
|
||||||
|
reminderOffsets?: number[];
|
||||||
|
remindMinutesBefore?: number | null;
|
||||||
|
}): number[] {
|
||||||
|
if (input.reminderOffsets !== undefined) {
|
||||||
|
return normalizeReminderOffsets(input.reminderOffsets);
|
||||||
|
}
|
||||||
|
if (input.remindMinutesBefore != null) {
|
||||||
|
return normalizeReminderOffsets([input.remindMinutesBefore]);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
|
||||||
|
import { listReminderOffsets } from "@/modules/_core/reminders";
|
||||||
import { householdMembers } from "@/modules/_core/schema";
|
import { householdMembers } from "@/modules/_core/schema";
|
||||||
import { calendarEvents, calendars } from "../schema";
|
import { calendarEvents, calendars } from "../schema";
|
||||||
|
|
||||||
@@ -258,6 +260,19 @@ export async function searchEvents(query: string, householdId: string) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDefaultEventReminderOffsets(): Promise<number[]> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
return normalizeReminderOffsets(user.defaultEventReminderOffsets ?? [30]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventReminderOffsets(eventId: string): Promise<number[]> {
|
||||||
|
const parsed = z.string().uuid().parse(eventId);
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
const event = await getEventForScope({ householdId: household.id, userId: user.id }, parsed);
|
||||||
|
if (!event) throw new Error("Event not found");
|
||||||
|
return listReminderOffsets("calendar.event", parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function toEventDto(row: {
|
function toEventDto(row: {
|
||||||
id: string;
|
id: string;
|
||||||
calendarId: string;
|
calendarId: string;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export const eventBaseInput = z.object({
|
|||||||
allDay: z.boolean().default(false),
|
allDay: z.boolean().default(false),
|
||||||
location: z.string().trim().max(300).nullable().optional(),
|
location: z.string().trim().max(300).nullable().optional(),
|
||||||
notes: z.string().trim().max(3000).nullable().optional(),
|
notes: z.string().trim().max(3000).nullable().optional(),
|
||||||
|
reminderOffsets: z.array(z.number().int().min(0)).optional(),
|
||||||
|
/** @deprecated use reminderOffsets */
|
||||||
remindMinutesBefore: z.number().int().min(0).nullable().optional(),
|
remindMinutesBefore: z.number().int().min(0).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const CONTAINER_TYPES = [
|
|||||||
{ value: "raised-bed", label: "Raised bed" },
|
{ value: "raised-bed", label: "Raised bed" },
|
||||||
{ value: "window-box", label: "Window box" },
|
{ value: "window-box", label: "Window box" },
|
||||||
{ value: "single-pot", label: "Single pot" },
|
{ value: "single-pot", label: "Single pot" },
|
||||||
|
{ value: "ikea-cabinet", label: "Ikea Cabinet" },
|
||||||
|
{ value: "acrylic-case", label: "Acrylic Case" },
|
||||||
{ value: "outdoor", label: "Outdoor" },
|
{ value: "outdoor", label: "Outdoor" },
|
||||||
{ value: "other", label: "Other" },
|
{ value: "other", label: "Other" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const CATEGORIES = [
|
|||||||
{ value: "flower", label: "Flower" },
|
{ value: "flower", label: "Flower" },
|
||||||
{ value: "succulent", label: "Succulent" },
|
{ value: "succulent", label: "Succulent" },
|
||||||
{ value: "cactus", label: "Cactus" },
|
{ value: "cactus", label: "Cactus" },
|
||||||
|
{ value: "carnivore", label: "Carnivore" },
|
||||||
{ value: "tropical", label: "Tropical" },
|
{ value: "tropical", label: "Tropical" },
|
||||||
{ value: "tree", label: "Tree" },
|
{ value: "tree", label: "Tree" },
|
||||||
{ value: "shrub", label: "Shrub" },
|
{ value: "shrub", label: "Shrub" },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Archive, Plus, Trash2 } from "lucide-react";
|
import { Archive, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, useTransition } from "react";
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { DetailBackLink } from "@/components/detail-back-link";
|
import { DetailBackLink } from "@/components/detail-back-link";
|
||||||
|
import { EntityComments } from "@/components/comments/entity-comments";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ShareButton } from "@/components/share-button";
|
import { ShareButton } from "@/components/share-button";
|
||||||
@@ -18,7 +19,13 @@ import {
|
|||||||
updateItem,
|
updateItem,
|
||||||
} from "../server/actions";
|
} from "../server/actions";
|
||||||
|
|
||||||
export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
|
export function ListDetail({
|
||||||
|
initialList,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
initialList: ListDetailDto;
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [list, setList] = useState(initialList);
|
const [list, setList] = useState(initialList);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@@ -156,6 +163,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
|
|||||||
<ListItemRow
|
<ListItemRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
currentUserId={currentUserId}
|
||||||
onToggle={(done) => setItemDone(item, done)}
|
onToggle={(done) => setItemDone(item, done)}
|
||||||
onEdit={(text) => editItemText(item, text)}
|
onEdit={(text) => editItemText(item, text)}
|
||||||
onCommit={() => commitItemText(item)}
|
onCommit={() => commitItemText(item)}
|
||||||
@@ -170,6 +178,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
|
|||||||
<ListItemRow
|
<ListItemRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
currentUserId={currentUserId}
|
||||||
onToggle={(done) => setItemDone(item, done)}
|
onToggle={(done) => setItemDone(item, done)}
|
||||||
onEdit={(text) => editItemText(item, text)}
|
onEdit={(text) => editItemText(item, text)}
|
||||||
onCommit={() => commitItemText(item)}
|
onCommit={() => commitItemText(item)}
|
||||||
@@ -179,18 +188,22 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EntityComments entityType="lists.list" entityId={list.id} currentUserId={currentUserId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListItemRow({
|
function ListItemRow({
|
||||||
item,
|
item,
|
||||||
|
currentUserId,
|
||||||
onToggle,
|
onToggle,
|
||||||
onEdit,
|
onEdit,
|
||||||
onCommit,
|
onCommit,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
item: ListItemDto;
|
item: ListItemDto;
|
||||||
|
currentUserId: string;
|
||||||
onToggle: (done: boolean) => void;
|
onToggle: (done: boolean) => void;
|
||||||
onEdit: (text: string) => void;
|
onEdit: (text: string) => void;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
@@ -333,6 +346,14 @@ function ListItemRow({
|
|||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-[14px] pb-2">
|
||||||
|
<EntityComments
|
||||||
|
entityType="lists.item"
|
||||||
|
entityId={item.id}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronDown, ChevronRight, ExternalLink, Plus } from "lucide-react";
|
import { ChevronDown, ChevronRight, ExternalLink, Pencil, Plus } from "lucide-react";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import type { ListIndexItem, ListWithItemsDto } from "../server/queries";
|
import type { ListIndexItem, ListWithItemsDto } from "../server/queries";
|
||||||
import { createList, toggleItem } from "../server/actions";
|
import { addItem, createList, toggleItem, updateListProperties } from "../server/actions";
|
||||||
|
|
||||||
export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
||||||
const [listRows, setListRows] = useState(lists);
|
const [listRows, setListRows] = useState(lists);
|
||||||
@@ -61,6 +61,44 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAddItem(listId: string, text: string) {
|
||||||
|
setListRows((current) =>
|
||||||
|
current.map((list) =>
|
||||||
|
list.id !== listId
|
||||||
|
? list
|
||||||
|
: {
|
||||||
|
...list,
|
||||||
|
openCount: list.openCount + 1,
|
||||||
|
items: [
|
||||||
|
...list.items,
|
||||||
|
{
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
text,
|
||||||
|
done: false,
|
||||||
|
position: list.items.length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await addItem({ listId, text });
|
||||||
|
const { listListsWithItems } = await import("../server/queries");
|
||||||
|
const refreshed = await listListsWithItems();
|
||||||
|
setListRows(refreshed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateList(listId: string, patch: { name?: string; type?: string }) {
|
||||||
|
setListRows((current) =>
|
||||||
|
current.map((list) => (list.id === listId ? { ...list, ...patch } : list)),
|
||||||
|
);
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateListProperties({ id: listId, ...patch });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto grid w-full max-w-5xl gap-6">
|
<div className="mx-auto grid w-full max-w-5xl gap-6">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
@@ -101,7 +139,13 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
|||||||
<div className="eyebrow">{groupType}</div>
|
<div className="eyebrow">{groupType}</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{groupLists.map((list) => (
|
{groupLists.map((list) => (
|
||||||
<ListCard key={list.id} list={list} onToggle={handleToggle} />
|
<ListCard
|
||||||
|
key={list.id}
|
||||||
|
list={list}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onAddItem={handleAddItem}
|
||||||
|
onUpdateList={handleUpdateList}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -114,11 +158,34 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
|||||||
function ListCard({
|
function ListCard({
|
||||||
list,
|
list,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onAddItem,
|
||||||
|
onUpdateList,
|
||||||
}: {
|
}: {
|
||||||
list: ListWithItemsDto;
|
list: ListWithItemsDto;
|
||||||
onToggle: (listId: string, item: ListIndexItem, done: boolean) => void;
|
onToggle: (listId: string, item: ListIndexItem, done: boolean) => void;
|
||||||
|
onAddItem: (listId: string, text: string) => void;
|
||||||
|
onUpdateList: (listId: string, patch: { name?: string; type?: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draftName, setDraftName] = useState(list.name);
|
||||||
|
const [draftType, setDraftType] = useState(list.type);
|
||||||
|
const [itemDraft, setItemDraft] = useState("");
|
||||||
|
|
||||||
|
function saveListProperties() {
|
||||||
|
const name = draftName.trim();
|
||||||
|
const type = draftType.trim();
|
||||||
|
if (!name || !type) return;
|
||||||
|
onUpdateList(list.id, { name, type });
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitItem() {
|
||||||
|
const text = itemDraft.trim();
|
||||||
|
if (!text) return;
|
||||||
|
onAddItem(list.id, text);
|
||||||
|
setItemDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -135,22 +202,60 @@ function ListCard({
|
|||||||
>
|
>
|
||||||
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="serif text-[15px] truncate text-[var(--ink)] m-0 font-medium">
|
{editing ? (
|
||||||
{list.name}
|
<div className="grid gap-2">
|
||||||
</h3>
|
<Input value={draftName} onChange={(e) => setDraftName(e.target.value)} />
|
||||||
<div className="meta">
|
<Input value={draftType} onChange={(e) => setDraftType(e.target.value)} />
|
||||||
{list.openCount} open · {list.doneCount} done
|
<div className="flex gap-2">
|
||||||
</div>
|
<Button type="button" size="sm" onClick={saveListProperties}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setDraftName(list.name);
|
||||||
|
setDraftType(list.type);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="serif text-[15px] truncate text-[var(--ink)] m-0 font-medium">
|
||||||
|
{list.name}
|
||||||
|
</h3>
|
||||||
|
<div className="meta">
|
||||||
|
{list.type} · {list.openCount} open · {list.doneCount} done
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
href={`/lists/${list.id}`}
|
{!editing && (
|
||||||
className="shrink-0 text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
|
<button
|
||||||
aria-label={`Open ${list.name}`}
|
type="button"
|
||||||
>
|
onClick={() => setEditing(true)}
|
||||||
<ExternalLink className="size-4" />
|
className="text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
|
||||||
</Link>
|
aria-label={`Edit ${list.name}`}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/lists/${list.id}`}
|
||||||
|
className="text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
|
||||||
|
aria-label={`Open ${list.name}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@@ -204,6 +309,23 @@ function ListCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 px-[14px] py-3 border-t border-[var(--hair)]">
|
||||||
|
<Input
|
||||||
|
value={itemDraft}
|
||||||
|
onChange={(e) => setItemDraft(e.target.value)}
|
||||||
|
placeholder="Add a task…"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitItem();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" size="sm" disabled={!itemDraft.trim()} onClick={submitItem}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,16 +59,20 @@ export async function updateListForScope(
|
|||||||
.update(lists)
|
.update(lists)
|
||||||
.set({
|
.set({
|
||||||
name: parsed.name,
|
name: parsed.name,
|
||||||
|
type: parsed.type,
|
||||||
archived: parsed.archived,
|
archived: parsed.archived,
|
||||||
})
|
})
|
||||||
.where(eq(lists.id, parsed.id));
|
.where(eq(lists.id, parsed.id));
|
||||||
|
|
||||||
if (parsed.name) {
|
if (parsed.name || parsed.type) {
|
||||||
await logActivityForScope(toScope(scope), {
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "lists.list",
|
entityType: "lists.list",
|
||||||
entityId: parsed.id,
|
entityId: parsed.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
payload: { name: parsed.name },
|
payload: {
|
||||||
|
...(parsed.name ? { name: parsed.name } : {}),
|
||||||
|
...(parsed.type ? { type: parsed.type } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (parsed.archived === true) {
|
if (parsed.archived === true) {
|
||||||
@@ -93,6 +97,20 @@ export async function renameList(input: { id: string; name: string }) {
|
|||||||
revalidatePath(`/lists/${parsed.id}`);
|
revalidatePath(`/lists/${parsed.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateListProperties(input: { id: string; name?: string; type?: string }) {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: listInput.shape.name.optional(),
|
||||||
|
type: listInput.shape.type.optional(),
|
||||||
|
})
|
||||||
|
.parse(input);
|
||||||
|
const { household, user } = await getCurrentSession();
|
||||||
|
await updateListForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
|
||||||
|
revalidatePath("/lists");
|
||||||
|
revalidatePath(`/lists/${parsed.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function archiveList(input: { id: string }) {
|
export async function archiveList(input: { id: string }) {
|
||||||
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
const { household, user } = await getCurrentSession();
|
const { household, user } = await getCurrentSession();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const listInput = z.object({
|
|||||||
|
|
||||||
export const listUpdateInput = z.object({
|
export const listUpdateInput = z.object({
|
||||||
name: listInput.shape.name.optional(),
|
name: listInput.shape.name.optional(),
|
||||||
|
type: listInput.shape.type.optional(),
|
||||||
archived: z.boolean().optional(),
|
archived: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Pin, PinOff, Save, Trash2 } from "lucide-react";
|
import { Bell, Pencil, Pin, PinOff, Save, Trash2, X } from "lucide-react";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { RichTextContent } from "@/components/rich-text";
|
import { EntityComments } from "@/components/comments/entity-comments";
|
||||||
import { DetailBackLink } from "@/components/detail-back-link";
|
import { DetailBackLink } from "@/components/detail-back-link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { ShareButton } from "@/components/share-button";
|
import { ShareButton } from "@/components/share-button";
|
||||||
import type { NoteDto } from "../server/queries";
|
import type { NoteDto } from "../server/queries";
|
||||||
import { createNote, deleteNote, setNotePinned, updateNote } from "../server/actions";
|
import { createNote, deleteNote, setNotePinned, updateNote } from "../server/actions";
|
||||||
|
import { NoteRichTextBody } from "./note-rich-text-body";
|
||||||
|
|
||||||
const RichTextEditor = dynamic(
|
const RichTextEditor = dynamic(
|
||||||
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
|
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
|
||||||
@@ -25,9 +26,10 @@ const RichTextEditor = dynamic(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export function NoteEditor({ note }: { note?: NoteDto }) {
|
export function NoteEditor({ note, currentUserId }: { note?: NoteDto; currentUserId?: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [currentNote, setCurrentNote] = useState(note);
|
const [currentNote, setCurrentNote] = useState(note);
|
||||||
|
const [editing, setEditing] = useState(!note);
|
||||||
const [title, setTitle] = useState(note?.title ?? "");
|
const [title, setTitle] = useState(note?.title ?? "");
|
||||||
const [body, setBody] = useState(note?.body ?? "");
|
const [body, setBody] = useState(note?.body ?? "");
|
||||||
const [remindAt, setRemindAt] = useState(toLocalDateTimeValue(note?.remindAt ?? null));
|
const [remindAt, setRemindAt] = useState(toLocalDateTimeValue(note?.remindAt ?? null));
|
||||||
@@ -35,6 +37,25 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
|
|||||||
|
|
||||||
const pinned = currentNote?.pinned ?? false;
|
const pinned = currentNote?.pinned ?? false;
|
||||||
|
|
||||||
|
function beginEdit() {
|
||||||
|
if (!currentNote) return;
|
||||||
|
setTitle(currentNote.title);
|
||||||
|
setBody(currentNote.body);
|
||||||
|
setRemindAt(toLocalDateTimeValue(currentNote.remindAt));
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
if (!currentNote) {
|
||||||
|
router.push("/notes");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTitle(currentNote.title);
|
||||||
|
setBody(currentNote.body);
|
||||||
|
setRemindAt(toLocalDateTimeValue(currentNote.remindAt));
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
function saveNote() {
|
function saveNote() {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (currentNote) {
|
if (currentNote) {
|
||||||
@@ -45,6 +66,7 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
|
|||||||
remindAt: remindAt ? new Date(remindAt) : null,
|
remindAt: remindAt ? new Date(remindAt) : null,
|
||||||
});
|
});
|
||||||
setCurrentNote(updated);
|
setCurrentNote(updated);
|
||||||
|
setEditing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,38 +94,87 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayTitle = currentNote?.title || title || "New note";
|
||||||
|
const updatedLabel = currentNote
|
||||||
|
? new Date(currentNote.updatedAt).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const reminderLabel = currentNote?.remindAt
|
||||||
|
? new Date(currentNote.remindAt).toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto grid w-full max-w-6xl gap-4 min-w-0">
|
<div className="mx-auto grid w-full max-w-3xl gap-4 min-w-0">
|
||||||
<DetailBackLink href="/notes" label="Notes" />
|
<DetailBackLink href="/notes" label="Notes" />
|
||||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{pinned && <Pin className="size-3.5 text-[var(--accent)]" />}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<h2 className="serif text-[24px] font-medium tracking-tight truncate">
|
{pinned ? <Pin className="size-3.5 shrink-0 text-[var(--accent)]" /> : null}
|
||||||
{currentNote ? currentNote.title : "New note"}
|
<h2 className="serif text-[24px] font-medium tracking-tight truncate">
|
||||||
</h2>
|
{displayTitle}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{!editing && currentNote ? (
|
||||||
|
<p className="muted text-[13px] mt-1 inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
{pinned ? <span>Pinned</span> : null}
|
||||||
|
{pinned && updatedLabel ? <span>·</span> : null}
|
||||||
|
{updatedLabel ? <span>Updated {updatedLabel}</span> : null}
|
||||||
|
{reminderLabel ? (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Bell className="size-3" />
|
||||||
|
{reminderLabel}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{currentNote ? (
|
{currentNote ? (
|
||||||
<Button variant="outline" size="sm" onClick={togglePinned} disabled={isPending}>
|
<Button variant="outline" size="sm" onClick={togglePinned} disabled={isPending}>
|
||||||
{pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />}
|
{pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />}
|
||||||
{pinned ? "Unpin note" : "Pin note"}
|
{pinned ? "Unpin" : "Pin"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{currentNote ? <ShareButton entityType="notes.note" entityId={currentNote.id} /> : null}
|
{currentNote ? <ShareButton entityType="notes.note" entityId={currentNote.id} /> : null}
|
||||||
{currentNote ? (
|
{currentNote ? (
|
||||||
<Button variant="destructive" size="sm" onClick={removeNote} disabled={isPending}>
|
<Button variant="destructive" size="sm" onClick={removeNote} disabled={isPending}>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
Delete note
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button size="sm" onClick={saveNote} disabled={!title.trim() || isPending}>
|
{editing ? (
|
||||||
<Save className="size-3.5" />
|
<>
|
||||||
Save note
|
<Button variant="outline" size="sm" onClick={cancelEdit} disabled={isPending}>
|
||||||
</Button>
|
<X className="size-3.5" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={saveNote} disabled={!title.trim() || isPending}>
|
||||||
|
<Save className="size-3.5" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={beginEdit}>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)] min-w-0">
|
{editing ? (
|
||||||
<section
|
<section
|
||||||
className="grid gap-4 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)] min-w-0"
|
className="grid gap-4 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)] min-w-0"
|
||||||
style={{ borderColor: "var(--hair)" }}
|
style={{ borderColor: "var(--hair)" }}
|
||||||
@@ -137,16 +208,26 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : currentNote ? (
|
||||||
<aside
|
<section
|
||||||
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 text-[var(--ink)] shadow-[var(--shadow-1)] min-w-0"
|
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)] min-w-0 text-[var(--ink)]"
|
||||||
style={{ borderColor: "var(--hair)" }}
|
style={{ borderColor: "var(--hair)" }}
|
||||||
aria-label="Preview"
|
|
||||||
>
|
>
|
||||||
<div className="eyebrow mb-3">Preview</div>
|
{currentNote.body ? (
|
||||||
<RichTextContent html={body} />
|
<NoteRichTextBody noteId={currentNote.id} body={currentNote.body} />
|
||||||
</aside>
|
) : (
|
||||||
</div>
|
<p className="text-sm muted">No body text.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{currentNote && currentUserId ? (
|
||||||
|
<EntityComments
|
||||||
|
entityType="notes.note"
|
||||||
|
entityId={currentNote.id}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
async function ensureSignedIn(page: Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
const devLogin = page.getByRole("button", { name: "Dev login" });
|
||||||
|
if (await devLogin.isVisible().catch(() => false)) {
|
||||||
|
await devLogin.click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("assistant bubble is hidden until opted in", async ({ page }) => {
|
test("assistant bubble is hidden until opted in", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
@@ -6,17 +15,37 @@ test("assistant bubble is hidden until opted in", async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("assistant chat smoke after opt-in", async ({ page }) => {
|
test("assistant chat smoke after opt-in", async ({ page }) => {
|
||||||
|
await ensureSignedIn(page);
|
||||||
await page.goto("/settings?s=appearance");
|
await page.goto("/settings?s=appearance");
|
||||||
const assistantSwitch = page.getByRole("switch", { name: "AI assistant" });
|
const assistantSwitch = page.getByRole("switch", { name: "AI assistant" });
|
||||||
if (!(await assistantSwitch.isChecked())) {
|
if (!(await assistantSwitch.isChecked())) {
|
||||||
await assistantSwitch.click();
|
await assistantSwitch.click();
|
||||||
}
|
}
|
||||||
|
await expect(assistantSwitch).toBeChecked();
|
||||||
|
const routeSelector = page.getByRole("combobox", { name: "Assistant model route" });
|
||||||
|
await expect(routeSelector).toBeVisible();
|
||||||
|
await routeSelector.selectOption("auto");
|
||||||
|
await expect(routeSelector).toHaveValue("auto");
|
||||||
|
await expect(routeSelector).toBeEnabled();
|
||||||
|
await routeSelector.selectOption("uncensored");
|
||||||
|
await expect(routeSelector).toHaveValue("uncensored");
|
||||||
|
await expect(routeSelector).toBeEnabled();
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("button", { name: "Open assistant" }).click();
|
await page.getByRole("button", { name: "Open assistant" }).click();
|
||||||
await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible();
|
await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("combobox", { name: "Assistant model route" })).toHaveCount(0);
|
||||||
|
const modelSelector = page.getByRole("combobox", { name: "Assistant model", exact: true });
|
||||||
|
await expect(modelSelector).toBeVisible();
|
||||||
|
const refreshModels = page.getByRole("button", { name: "Refresh assistant models" });
|
||||||
|
await expect(refreshModels).toBeVisible();
|
||||||
|
await refreshModels.click();
|
||||||
|
await expect.poll(() => modelSelector.evaluate((node) => node.tagName)).toBe("SELECT");
|
||||||
|
await expect
|
||||||
|
.poll(() => modelSelector.evaluate((node) => node.getBoundingClientRect().height))
|
||||||
|
.toBeGreaterThanOrEqual(36);
|
||||||
|
|
||||||
await page.getByLabel("Assistant message").fill("hello assistant");
|
await page.getByLabel("Message for Assistant").fill("hello assistant");
|
||||||
await page.getByRole("button", { name: "Send" }).click();
|
await page.getByRole("button", { name: "Send" }).click();
|
||||||
|
|
||||||
await expect(page.getByText("hello assistant")).toBeVisible();
|
await expect(page.getByText("hello assistant")).toBeVisible();
|
||||||
|
|||||||
@@ -71,4 +71,79 @@ describe("runAgentChat", () => {
|
|||||||
assert.equal(result.toolCalls[0]?.name, "add_list_item");
|
assert.equal(result.toolCalls[0]?.name, "add_list_item");
|
||||||
assert.equal(result.toolCalls[0]?.status, 201);
|
assert.equal(result.toolCalls[0]?.status, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a custom system prompt override", async () => {
|
||||||
|
const result = await runAgentChat({
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
request: new Request("http://localhost:3000/api/agent/chat"),
|
||||||
|
systemPrompt: "You are a pirate.",
|
||||||
|
llm: createMockLlmClient(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.message.role, "assistant");
|
||||||
|
assert.ok(result.message.content.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends runtime clock context to the system prompt", async () => {
|
||||||
|
const original = process.env.HOUSEHOLD_TIMEZONE;
|
||||||
|
process.env.HOUSEHOLD_TIMEZONE = "America/Chicago";
|
||||||
|
|
||||||
|
let systemContent = "";
|
||||||
|
const result = await runAgentChat({
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
request: new Request("http://localhost:3000/api/agent/chat"),
|
||||||
|
systemPrompt: "You are a pirate.",
|
||||||
|
llm: {
|
||||||
|
async chatCompletion(request) {
|
||||||
|
const system = request.messages.find((message) => message.role === "system");
|
||||||
|
systemContent = typeof system?.content === "string" ? system.content : "";
|
||||||
|
return {
|
||||||
|
message: { role: "assistant", content: "Ahoy" },
|
||||||
|
finishReason: "stop",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.message.content, "Ahoy");
|
||||||
|
assert.match(systemContent, /^You are a pirate\./);
|
||||||
|
assert.match(systemContent, /Current time:/);
|
||||||
|
assert.match(systemContent, /America\/Chicago/);
|
||||||
|
|
||||||
|
if (original === undefined) delete process.env.HOUSEHOLD_TIMEZONE;
|
||||||
|
else process.env.HOUSEHOLD_TIMEZONE = original;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a model override to the OpenAI-compatible client", async () => {
|
||||||
|
const originalBaseUrl = process.env.LLM_BASE_URL;
|
||||||
|
const originalModel = process.env.LLM_MODEL;
|
||||||
|
const originalProvider = process.env.LLM_PROVIDER;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let requestBody: unknown = null;
|
||||||
|
|
||||||
|
process.env.LLM_BASE_URL = "https://llm.example.test/v1";
|
||||||
|
process.env.LLM_MODEL = "llama3.2";
|
||||||
|
delete process.env.LLM_PROVIDER;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
requestBody = JSON.parse(String(init?.body));
|
||||||
|
return Response.json({
|
||||||
|
choices: [{ message: { role: "assistant", content: "done" }, finish_reason: "stop" }],
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const { createLlmClient } = await import("../../src/lib/llm/index");
|
||||||
|
const client = createLlmClient({ model: "qwen2.5-coder" });
|
||||||
|
await client.chatCompletion({ messages: [{ role: "user", content: "hello" }] });
|
||||||
|
|
||||||
|
assert.equal((requestBody as { model?: string }).model, "qwen2.5-coder");
|
||||||
|
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalBaseUrl === undefined) delete process.env.LLM_BASE_URL;
|
||||||
|
else process.env.LLM_BASE_URL = originalBaseUrl;
|
||||||
|
if (originalModel === undefined) delete process.env.LLM_MODEL;
|
||||||
|
else process.env.LLM_MODEL = originalModel;
|
||||||
|
if (originalProvider === undefined) delete process.env.LLM_PROVIDER;
|
||||||
|
else process.env.LLM_PROVIDER = originalProvider;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
fingerprintToolCalls,
|
||||||
|
isSuccessfulWrite,
|
||||||
|
summarizeToolTrace,
|
||||||
|
truncateToolResult,
|
||||||
|
} from "../../src/modules/agent/server/loop-guards";
|
||||||
|
import { runAgentChat } from "../../src/modules/agent/server/run";
|
||||||
|
import type {
|
||||||
|
ChatCompletionRequest,
|
||||||
|
ChatCompletionResult,
|
||||||
|
LlmClient,
|
||||||
|
} from "../../src/lib/llm/types";
|
||||||
|
|
||||||
|
describe("loop-guards", () => {
|
||||||
|
it("fingerprints tool calls stably regardless of key order", () => {
|
||||||
|
const a = fingerprintToolCalls([
|
||||||
|
{ name: "create_event", arguments: '{"title":"Dentist","calendarName":"Family"}' },
|
||||||
|
]);
|
||||||
|
const b = fingerprintToolCalls([
|
||||||
|
{ name: "create_event", arguments: '{"calendarName":"Family","title":"Dentist"}' },
|
||||||
|
]);
|
||||||
|
assert.equal(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats create_event 201 as a successful write", () => {
|
||||||
|
assert.equal(isSuccessfulWrite("create_event", 201), true);
|
||||||
|
assert.equal(isSuccessfulWrite("list_calendars", 200), false);
|
||||||
|
assert.equal(
|
||||||
|
isSuccessfulWrite("call_api", 201, '{"method":"POST","path":"/api/v1/events"}'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isSuccessfulWrite("call_api", 200, '{"method":"GET","path":"/api/v1/events"}'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates oversized tool results", () => {
|
||||||
|
const result = truncateToolResult("x".repeat(7000), 100);
|
||||||
|
assert.ok(result.length < 200);
|
||||||
|
assert.match(result, /truncated/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes tool traces", () => {
|
||||||
|
assert.equal(
|
||||||
|
summarizeToolTrace([
|
||||||
|
{ name: "list_calendars", status: 200 },
|
||||||
|
{ name: "create_event", status: 201 },
|
||||||
|
]),
|
||||||
|
"list_calendars→200, create_event→201",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runAgentChat loop guards", () => {
|
||||||
|
it("stops after a successful write instead of looping", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const llm: LlmClient = {
|
||||||
|
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) {
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "create_event",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
title: "Dentist",
|
||||||
|
startAt: "2026-07-10T15:00:00.000Z",
|
||||||
|
endAt: "2026-07-10T16:00:00.000Z",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finishReason: "tool_calls",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(request.tools, undefined);
|
||||||
|
return {
|
||||||
|
message: { role: "assistant", content: "Added Dentist to your calendar." },
|
||||||
|
finishReason: "stop",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runAgentChat({
|
||||||
|
messages: [{ role: "user", content: "add dentist tomorrow at 10" }],
|
||||||
|
request: new Request("http://localhost:3000/api/agent/chat"),
|
||||||
|
llm,
|
||||||
|
executeTool: async () =>
|
||||||
|
JSON.stringify({ status: 201, body: { id: "evt-1", title: "Dentist" } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(result.toolCalls.length, 1);
|
||||||
|
assert.equal(result.toolCalls[0]?.name, "create_event");
|
||||||
|
assert.match(result.message.content, /Dentist/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks duplicate identical tool rounds", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const llm: LlmClient = {
|
||||||
|
async chatCompletion(): Promise<ChatCompletionResult> {
|
||||||
|
calls += 1;
|
||||||
|
if (calls <= 2) {
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: String(calls),
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "list_calendars",
|
||||||
|
arguments: "{}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finishReason: "tool_calls",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: { role: "assistant", content: "You have one Family calendar." },
|
||||||
|
finishReason: "stop",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runAgentChat({
|
||||||
|
messages: [{ role: "user", content: "what calendars do I have?" }],
|
||||||
|
request: new Request("http://localhost:3000/api/agent/chat"),
|
||||||
|
llm,
|
||||||
|
executeTool: async () =>
|
||||||
|
JSON.stringify({ status: 200, body: [{ id: "cal-1", name: "Family" }] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
assert.equal(result.toolCalls.length, 1);
|
||||||
|
assert.match(result.message.content, /Family/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { clientChatInputSchema } from "../../src/modules/agent/messages";
|
||||||
|
|
||||||
|
describe("clientChatInputSchema", () => {
|
||||||
|
it("accepts messages with image attachments", () => {
|
||||||
|
const parsed = clientChatInputSchema.safeParse({
|
||||||
|
stream: true,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "Add this appointment to the calendar",
|
||||||
|
attachments: [{ type: "image", url: "/api/uploads/assistant/house-1/photo.jpg" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.success, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty message content", () => {
|
||||||
|
const parsed = clientChatInputSchema.safeParse({
|
||||||
|
messages: [{ role: "user", content: " " }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.success, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an optional model ID", () => {
|
||||||
|
const parsed = clientChatInputSchema.safeParse({
|
||||||
|
model: "qwen2.5-coder",
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.success, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid model IDs", () => {
|
||||||
|
const parsed = clientChatInputSchema.safeParse({
|
||||||
|
model: "bad model",
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.success, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects whitespace-padded model IDs", () => {
|
||||||
|
const parsed = clientChatInputSchema.safeParse({
|
||||||
|
model: " qwen2.5-coder ",
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.success, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
describe("GET /api/agent/models", () => {
|
||||||
|
it("is dynamic and returns no-store responses", async () => {
|
||||||
|
process.env.DATABASE_URL ??= "postgres://famapp:famapp@localhost:5432/famapp";
|
||||||
|
|
||||||
|
const { GET, dynamic } = await import("../../src/app/api/agent/models/route");
|
||||||
|
|
||||||
|
assert.equal(dynamic, "force-dynamic");
|
||||||
|
|
||||||
|
const response = await GET(new Request("http://localhost/api/agent/models?refresh=1"));
|
||||||
|
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
assert.equal(response.headers.get("Cache-Control"), "no-store");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { appendAgentRuntimeContext, resolveHouseholdTimezone } from "../../src/modules/agent/tools";
|
||||||
|
import {
|
||||||
|
createApiToolExecutor,
|
||||||
|
resolveInternalApiBase,
|
||||||
|
} from "../../src/modules/agent/tool-executor";
|
||||||
|
|
||||||
|
describe("appendAgentRuntimeContext", () => {
|
||||||
|
it("appends current time and timezone to the prompt", () => {
|
||||||
|
const original = process.env.HOUSEHOLD_TIMEZONE;
|
||||||
|
process.env.HOUSEHOLD_TIMEZONE = "America/Chicago";
|
||||||
|
|
||||||
|
const now = new Date("2026-07-09T14:30:00.000Z");
|
||||||
|
const result = appendAgentRuntimeContext("Be helpful.", now);
|
||||||
|
|
||||||
|
assert.match(result, /^Be helpful\./);
|
||||||
|
assert.match(result, /Current time:/);
|
||||||
|
assert.match(result, /America\/Chicago/);
|
||||||
|
assert.match(result, /2026-07-09T14:30:00\.000Z/);
|
||||||
|
assert.match(result, /Resolve relative dates from this clock/);
|
||||||
|
|
||||||
|
if (original === undefined) delete process.env.HOUSEHOLD_TIMEZONE;
|
||||||
|
else process.env.HOUSEHOLD_TIMEZONE = original;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHouseholdTimezone", () => {
|
||||||
|
it("prefers HOUSEHOLD_TIMEZONE over TZ", () => {
|
||||||
|
const originalHousehold = process.env.HOUSEHOLD_TIMEZONE;
|
||||||
|
const originalTz = process.env.TZ;
|
||||||
|
process.env.HOUSEHOLD_TIMEZONE = "America/New_York";
|
||||||
|
process.env.TZ = "UTC";
|
||||||
|
|
||||||
|
assert.equal(resolveHouseholdTimezone(), "America/New_York");
|
||||||
|
|
||||||
|
if (originalHousehold === undefined) delete process.env.HOUSEHOLD_TIMEZONE;
|
||||||
|
else process.env.HOUSEHOLD_TIMEZONE = originalHousehold;
|
||||||
|
if (originalTz === undefined) delete process.env.TZ;
|
||||||
|
else process.env.TZ = originalTz;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveInternalApiBase", () => {
|
||||||
|
it("uses loopback instead of the public request origin", () => {
|
||||||
|
const original = process.env.INTERNAL_API_BASE_URL;
|
||||||
|
const originalPort = process.env.PORT;
|
||||||
|
delete process.env.INTERNAL_API_BASE_URL;
|
||||||
|
process.env.PORT = "3000";
|
||||||
|
|
||||||
|
const base = resolveInternalApiBase(new Request("https://fam.ginnoir.com/api/agent/chat"));
|
||||||
|
assert.equal(base, "http://127.0.0.1:3000");
|
||||||
|
|
||||||
|
if (original === undefined) delete process.env.INTERNAL_API_BASE_URL;
|
||||||
|
else process.env.INTERNAL_API_BASE_URL = original;
|
||||||
|
if (originalPort === undefined) delete process.env.PORT;
|
||||||
|
else process.env.PORT = originalPort;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors INTERNAL_API_BASE_URL when set", () => {
|
||||||
|
const original = process.env.INTERNAL_API_BASE_URL;
|
||||||
|
process.env.INTERNAL_API_BASE_URL = "http://127.0.0.1:3010/";
|
||||||
|
|
||||||
|
const base = resolveInternalApiBase(new Request("https://fam.ginnoir.com/api/agent/chat"));
|
||||||
|
assert.equal(base, "http://127.0.0.1:3010");
|
||||||
|
|
||||||
|
if (original === undefined) delete process.env.INTERNAL_API_BASE_URL;
|
||||||
|
else process.env.INTERNAL_API_BASE_URL = original;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create_event calendar resolution", () => {
|
||||||
|
it("uses the first calendar when calendarId and calendarName are omitted", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const posts: Array<{ path: string; body: unknown }> = [];
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/api/v1/calendars") && (!init?.method || init.method === "GET")) {
|
||||||
|
return Response.json([
|
||||||
|
{ id: "cal-1", name: "Family" },
|
||||||
|
{ id: "cal-2", name: "Work" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/v1/events") && init?.method === "POST") {
|
||||||
|
const body = JSON.parse(String(init.body));
|
||||||
|
posts.push({ path: url, body });
|
||||||
|
return Response.json({ id: "evt-1", ...body }, { status: 201 });
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const execute = createApiToolExecutor(
|
||||||
|
new Request("https://fam.ginnoir.com/api/agent/chat", {
|
||||||
|
headers: { cookie: "authjs.session-token=test" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = JSON.parse(
|
||||||
|
await execute(
|
||||||
|
"create_event",
|
||||||
|
JSON.stringify({
|
||||||
|
title: "Dentist",
|
||||||
|
startAt: "2026-07-10T15:00:00.000Z",
|
||||||
|
endAt: "2026-07-10T16:00:00.000Z",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
) as { status: number; body: { calendarId?: string } };
|
||||||
|
|
||||||
|
assert.equal(result.status, 201);
|
||||||
|
assert.equal(result.body.calendarId, "cal-1");
|
||||||
|
assert.equal(posts.length, 1);
|
||||||
|
assert.match(posts[0]!.path, /^http:\/\/127\.0\.0\.1:3000\/api\/v1\/events/);
|
||||||
|
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches calendarName case-insensitively", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/api/v1/calendars") && (!init?.method || init.method === "GET")) {
|
||||||
|
return Response.json([
|
||||||
|
{ id: "cal-1", name: "Family" },
|
||||||
|
{ id: "cal-2", name: "Work" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/v1/events") && init?.method === "POST") {
|
||||||
|
const body = JSON.parse(String(init.body));
|
||||||
|
return Response.json({ id: "evt-1", ...body }, { status: 201 });
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const execute = createApiToolExecutor(new Request("http://localhost:3000/api/agent/chat"));
|
||||||
|
const result = JSON.parse(
|
||||||
|
await execute(
|
||||||
|
"create_event",
|
||||||
|
JSON.stringify({
|
||||||
|
calendarName: "work",
|
||||||
|
title: "Standup",
|
||||||
|
startAt: "2026-07-10T15:00:00.000Z",
|
||||||
|
endAt: "2026-07-10T15:30:00.000Z",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
) as { status: number; body: { calendarId?: string } };
|
||||||
|
|
||||||
|
assert.equal(result.status, 201);
|
||||||
|
assert.equal(result.body.calendarId, "cal-2");
|
||||||
|
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a clear error when the internal API is unreachable", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
throw new TypeError("fetch failed");
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const execute = createApiToolExecutor(new Request("https://fam.ginnoir.com/api/agent/chat"));
|
||||||
|
const result = JSON.parse(await execute("list_calendars", "{}")) as {
|
||||||
|
status: number;
|
||||||
|
body: { error?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(result.status, 502);
|
||||||
|
assert.match(String(result.body.error), /unreachable/i);
|
||||||
|
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { transcribeAudioFile } from "../../src/lib/llm/transcribe";
|
||||||
|
|
||||||
|
describe("transcribeAudioFile", () => {
|
||||||
|
it("returns mock text when llm base url is unset", async () => {
|
||||||
|
const original = process.env.LLM_BASE_URL;
|
||||||
|
const originalProvider = process.env.LLM_PROVIDER;
|
||||||
|
delete process.env.LLM_BASE_URL;
|
||||||
|
delete process.env.LLM_PROVIDER;
|
||||||
|
|
||||||
|
const text = await transcribeAudioFile(
|
||||||
|
new Blob(["audio"], { type: "audio/wav" }),
|
||||||
|
"recording.wav",
|
||||||
|
);
|
||||||
|
assert.match(text, /milk/i);
|
||||||
|
|
||||||
|
if (original === undefined) delete process.env.LLM_BASE_URL;
|
||||||
|
else process.env.LLM_BASE_URL = original;
|
||||||
|
if (originalProvider === undefined) delete process.env.LLM_PROVIDER;
|
||||||
|
else process.env.LLM_PROVIDER = originalProvider;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
clearAssistantChat,
|
clearAssistantChat,
|
||||||
loadAssistantChat,
|
loadAssistantChat,
|
||||||
saveAssistantChat,
|
saveAssistantChat,
|
||||||
|
toClientChatMessage,
|
||||||
} from "../../src/modules/agent/assistant-chat-storage";
|
} from "../../src/modules/agent/assistant-chat-storage";
|
||||||
|
|
||||||
const storage = new Map<string, string>();
|
const storage = new Map<string, string>();
|
||||||
@@ -49,4 +50,15 @@ describe("assistant chat storage", () => {
|
|||||||
clearAssistantChat("user-a");
|
clearAssistantChat("user-a");
|
||||||
assert.deepEqual(loadAssistantChat("user-a"), []);
|
assert.deepEqual(loadAssistantChat("user-a"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps stored image messages to client attachments", () => {
|
||||||
|
const message = toClientChatMessage({
|
||||||
|
role: "user",
|
||||||
|
content: "read this",
|
||||||
|
imageUrl: "/api/uploads/assistant/home/photo.jpg",
|
||||||
|
});
|
||||||
|
assert.deepEqual(message.attachments, [
|
||||||
|
{ type: "image", url: "/api/uploads/assistant/home/photo.jpg" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user