Compare commits

...
8 Commits
Author SHA1 Message Date
ginnoir 3a5c6d056c chore: release v0.6.1
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Failing after 45s
2026-07-05 02:02:07 -05:00
ginnoir c8db5475d3 feat(agent): add voice input and photo attachments to assistant
Wire mic through Whisper-compatible transcriptions on LLM_BASE_URL.

Photos upload to MinIO and reach the vision model as base64 image_url parts.
2026-07-05 02:01:48 -05:00
ginnoir 876a283d47 ci: faster pipelines with checks-only ci and registry build cache
CI / checks (push) Failing after 2m8s
Drop duplicate Next.js builds from CI, skip release commits, and cancel stale runs.

Cache pnpm on runners and use buildx registry cache for releases.
2026-07-04 23:54:53 -05:00
ginnoir c80070f5c3 ci: skip workflow on docs-only changes
CI / checks (push) Failing after 2m15s
CI / build (push) Failing after 12m59s
Ignore pushes and PRs that only touch markdown or docs/ so the runner queue stays clear.
2026-07-04 23:40:10 -05:00
ginnoir a58a91d8b8 chore: release v0.6.0
CI / checks (push) Failing after 3m1s
CI / build (push) Successful in 5m17s
Release Image / build-and-push (push) Successful in 6m32s
2026-07-04 23:26:40 -05:00
ginnoir e5e509081c feat: per-user assistant name and system prompt customization
CI / checks (push) Failing after 2m12s
CI / build (push) Successful in 4m50s
Each user can rename the AI assistant and edit their own system prompt in Settings.
2026-07-04 23:26:17 -05:00
ginnoir a2e5eb111d docs: registry is primary image source, ghcr is backup mirror
CI / checks (push) Failing after 2m34s
CI / build (push) Successful in 4m38s
2026-07-04 23:03:56 -05:00
ginnoir cf50f20714 docs: record v0.5.6 prod deploy and migration repair
CI / checks (push) Failing after 2m13s
CI / build (push) Successful in 4m36s
2026-07-04 22:49:34 -05:00
43 changed files with 1091 additions and 190 deletions
+2
View File
@@ -49,6 +49,8 @@ OPENPLANTBOOK_CLIENT_SECRET=
# 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).
# 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_BASE_URL=
LLM_API_KEY=
+13 -20
View File
@@ -3,10 +3,21 @@ name: CI
on:
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
pull_request:
paths-ignore:
- "**/*.md"
- "docs/**"
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
if: "github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -15,6 +26,8 @@ jobs:
run: |
corepack enable
corepack prepare pnpm@10.33.3 --activate
mkdir -p /pnpm-store
pnpm config set store-dir /pnpm-store
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -27,23 +40,3 @@ jobs:
- name: 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
+33 -12
View File
@@ -1,11 +1,18 @@
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:
push:
tags:
- "v*"
workflow_dispatch:
concurrency:
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-push:
runs-on: ubuntu-latest
@@ -24,8 +31,15 @@ jobs:
echo "image=registry.ginnoir.com/ginnoir/famapp"
} >> "$GITHUB_OUTPUT"
- name: Install docker
run: apt-get update -qq && apt-get install -y -qq docker.io
- name: Ensure docker CLI
run: |
if command -v docker >/dev/null 2>&1; then
echo "docker already available"
docker version
exit 0
fi
apt-get update -qq && apt-get install -y -qq docker.io
docker version
- name: Login to registry
run: |
@@ -33,16 +47,23 @@ jobs:
--username "${{ secrets.REGISTRY_PUSH_USERNAME }}" \
--password-stdin
- name: Build image
- name: Set up buildx
run: |
docker build \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}" \
-t "${{ steps.meta.outputs.image }}:latest" \
.
docker buildx create --name famapp-builder --use 2>/dev/null || docker buildx use famapp-builder
docker buildx inspect --bootstrap
- 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: |
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}"
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}"
docker push "${{ steps.meta.outputs.image }}:latest"
docker buildx build \
--push \
--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
View File
@@ -3,10 +3,21 @@ name: CI
on:
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
pull_request:
paths-ignore:
- "**/*.md"
- "docs/**"
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
if: "github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -27,33 +38,3 @@ jobs:
- run: pnpm lint
- 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
+4 -1
View File
@@ -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:
push:
+17
View File
@@ -1,5 +1,22 @@
# Changelog
## [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
+1 -1
View File
@@ -4,7 +4,7 @@ Self-hosted family coordination web app. Shared calendar, lists, notes, and gard
[![CI](https://github.com/ginnoir/famapp/actions/workflows/ci.yml/badge.svg)](https://github.com/ginnoir/famapp/actions/workflows/ci.yml)
[![Latest release](https://img.shields.io/github/v/release/ginnoir/famapp?sort=semver)](https://github.com/ginnoir/famapp/releases)
[![Docker image](https://img.shields.io/badge/image-ghcr.io%2Fginnoir%2Ffamapp-blue?logo=docker)](https://github.com/ginnoir/famapp/pkgs/container/famapp)
[![Docker image](https://img.shields.io/badge/image-registry.ginnoir.com%2Fginnoir%2Ffamapp-blue?logo=docker)](https://gitea.ginnoir.com/ginnoir/famapp)
[![Node ≥ 22](https://img.shields.io/badge/node-%E2%89%A522-brightgreen?logo=node.js)](https://nodejs.org)
---
+2 -2
View File
@@ -31,7 +31,7 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
- **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). Migration journal entries for `0022`/`0023` (prod apply fix); dashboard edit previews keyed by placement index (`bangs.stats` live in edit mode); notes comments; comment revalidation on `/notes`.
- **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.
- **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.
@@ -83,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 79, 1112, 1519.
**How to resume:** P2 batch #28#31 complete (released v0.5.6). Next: remaining P2/P3 Gitea backlog (#32+) 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
+5 -1
View File
@@ -15,12 +15,16 @@ pnpm release:patch # or :minor / :major
# 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:0.x` (minor alias)
- `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
On the home server, in `/srv/famapp/deploy/`:
@@ -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;
+7
View File
@@ -169,6 +169,13 @@
"when": 1780393000000,
"tag": "0023_comments",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1780394000000,
"tag": "0024_user_assistant_customization",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "famapp",
"version": "0.5.6",
"version": "0.6.1",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
+9 -18
View File
@@ -1,35 +1,24 @@
import { z } from "zod";
import { apiError, apiJson } from "@/lib/api-handler";
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 { clientChatInputSchema } from "@/modules/agent/messages";
import { encodeSseEvent } from "@/modules/agent/server/progress";
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) {
const auth = await resolveApiAuth(request);
if (!auth?.userId) {
return apiError("Unauthorized", 401);
}
const assistantEnabled = await getAssistantEnabled(auth.userId);
if (!assistantEnabled) {
const assistant = await getAssistantPreferences(auth.userId);
if (!assistant.enabled) {
return apiError("Assistant not enabled", 403);
}
const systemPrompt = resolveAssistantSystemPrompt(assistant.systemPrompt);
let body: unknown;
try {
body = await request.json();
@@ -37,7 +26,7 @@ export async function POST(request: Request) {
return apiError("Invalid JSON body", 400);
}
const parsed = chatInput.safeParse(body);
const parsed = clientChatInputSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
}
@@ -54,6 +43,7 @@ export async function POST(request: Request) {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
systemPrompt,
onProgress: send,
});
@@ -87,6 +77,7 @@ export async function POST(request: Request) {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
systemPrompt,
});
return apiJson({
+51
View File
@@ -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);
}
}
+3 -1
View File
@@ -33,7 +33,9 @@ export async function POST(request: Request) {
}
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/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
+9 -1
View File
@@ -18,6 +18,7 @@ import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster";
import { AssistantBubble } from "@/modules/agent/components/assistant-bubble";
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
import { isLlmConfigured } from "@/lib/llm";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -102,6 +103,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let userDashboards: DashboardMeta[] = [];
let signedIn = false;
let assistantEnabled = false;
let assistantName = DEFAULT_ASSISTANT_NAME;
const session = await auth();
if (session?.user?.id) {
@@ -114,6 +116,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle,
assistantEnabled: users.assistantEnabled,
assistantName: users.assistantName,
})
.from(users)
.where(eq(users.id, session.user.id))
@@ -125,6 +128,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle;
assistantEnabled = row.assistantEnabled;
assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME;
}
userDashboards = await db
.select({
@@ -178,7 +182,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<InstallPrompt />
<PwaRegister />
{signedIn && assistantEnabled && session?.user?.id ? (
<AssistantBubble configured={isLlmConfigured()} userId={session.user.id} />
<AssistantBubble
configured={isLlmConfigured()}
userId={session.user.id}
assistantName={assistantName}
/>
) : null}
<AppToaster position="bottom-right" />
</QuickAddProvider>
+47 -2
View File
@@ -2,13 +2,58 @@
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import {
DEFAULT_ASSISTANT_NAME,
MAX_ASSISTANT_NAME_LENGTH,
MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH,
} from "@/lib/assistant-config";
import { users } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session";
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> {
const { user } = await getCurrentSession();
await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id));
revalidatePath("/settings");
revalidatePath("/", "layout");
revalidateAssistantSurfaces();
}
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 resetAssistantName(): Promise<void> {
await setAssistantName(DEFAULT_ASSISTANT_NAME);
}
export async function resetAssistantSystemPrompt(): Promise<void> {
await setAssistantSystemPrompt(null);
}
+10 -2
View File
@@ -13,7 +13,8 @@ import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions";
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
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 { listLists } from "@/modules/lists/server/queries";
@@ -322,6 +323,8 @@ function AppearanceSection({
themeCalView: string;
themeNavStyle: string;
assistantEnabled: boolean;
assistantName: string;
assistantSystemPrompt: string | null;
};
}) {
return (
@@ -355,7 +358,12 @@ function AppearanceSection({
<MessageCircle className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<AssistantOptIn enabled={user.assistantEnabled} />
<AssistantSettings
enabled={user.assistantEnabled}
name={user.assistantName}
systemPrompt={user.assistantSystemPrompt}
defaultSystemPrompt={AGENT_SYSTEM_PROMPT}
/>
</CardContent>
</Card>
</>
-35
View File
@@ -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>
);
}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import {
resetAssistantName,
resetAssistantSystemPrompt,
setAssistantEnabled,
setAssistantName,
setAssistantSystemPrompt,
} from "@/app/settings/assistant-actions";
import { DEFAULT_ASSISTANT_NAME, MAX_ASSISTANT_NAME_LENGTH } from "@/lib/assistant-config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
type Props = {
enabled: boolean;
name: string;
systemPrompt: string | null;
defaultSystemPrompt: string;
};
export function AssistantSettings({ enabled, name, systemPrompt, defaultSystemPrompt }: Props) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const effectivePrompt = systemPrompt ?? defaultSystemPrompt;
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 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="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>
);
}
+10
View File
@@ -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;
}
+30 -3
View File
@@ -1,13 +1,40 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
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;
};
export async function getAssistantPreferences(userId: string): Promise<AssistantPreferences> {
const [row] = await db
.select({ assistantEnabled: users.assistantEnabled })
.select({
assistantEnabled: users.assistantEnabled,
assistantName: users.assistantName,
assistantSystemPrompt: users.assistantSystemPrompt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
return row?.assistantEnabled ?? false;
return {
enabled: row?.assistantEnabled ?? false,
name: row?.assistantName?.trim() || DEFAULT_ASSISTANT_NAME,
systemPrompt: row?.assistantSystemPrompt ?? null,
};
}
export async function getAssistantEnabled(userId: string): Promise<boolean> {
const prefs = await getAssistantPreferences(userId);
return prefs.enabled;
}
+28
View File
@@ -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;
}
+4 -11
View File
@@ -1,14 +1,5 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
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 "";
}
import { textFromMessageContent } from "./content";
function hadToolResults(messages: ChatCompletionRequest["messages"]): boolean {
return messages.some((message) => message.role === "tool");
@@ -17,7 +8,9 @@ function hadToolResults(messages: ChatCompletionRequest["messages"]): boolean {
export function createMockLlmClient(): LlmClient {
return {
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)) {
return {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
import type { ChatCompletionRequest, ChatCompletionResult, ChatMessage, LlmClient } from "./types";
type OpenAiMessage = {
role: string;
content: string | null;
content: ChatMessage["content"];
tool_calls?: Array<{
id: string;
type: "function";
+47
View File
@@ -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
View File
@@ -1,5 +1,19 @@
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 = {
id: string;
type: "function";
@@ -11,7 +25,7 @@ export type ChatToolCall = {
export type ChatMessage = {
role: ChatRole;
content: string | null;
content: string | ChatContentPart[] | null;
tool_calls?: ChatToolCall[];
tool_call_id?: string;
name?: string;
+2
View File
@@ -36,6 +36,8 @@ export const users = pgTable("users", {
notifInApp: boolean("notif_inapp").notNull().default(true),
notifNtfy: boolean("notif_ntfy").notNull().default(false),
assistantEnabled: boolean("assistant_enabled").notNull().default(false),
assistantName: text("assistant_name").notNull().default("Assistant"),
assistantSystemPrompt: text("assistant_system_prompt"),
defaultEventReminderOffsets: jsonb("default_event_reminder_offsets")
.notNull()
.$type<number[]>()
+20 -6
View File
@@ -1,9 +1,12 @@
import type { ClientChatMessage } from "./messages";
export type AssistantChatMessage = {
role: "user" | "assistant";
content: string;
imageUrl?: string;
};
const STORAGE_VERSION = "v1";
const STORAGE_VERSION = "v2";
const MAX_MESSAGES = 40;
function storageKey(userId: string) {
@@ -13,11 +16,22 @@ function storageKey(userId: string) {
function isValidMessage(value: unknown): value is AssistantChatMessage {
if (!value || typeof value !== "object") return false;
const row = value as Record<string, unknown>;
return (
(row.role === "user" || row.role === "assistant") &&
typeof row.content === "string" &&
row.content.trim().length > 0
);
if (row.role !== "user" && row.role !== "assistant") return false;
if (typeof row.content !== "string" || row.content.trim().length === 0) return false;
if (row.imageUrl !== undefined && typeof row.imageUrl !== "string") 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[] {
@@ -7,9 +7,10 @@ import { AssistantPanel } from "./assistant-panel";
type Props = {
configured: boolean;
userId: string;
assistantName: string;
};
export function AssistantBubble({ configured, userId }: Props) {
export function AssistantBubble({ configured, userId, assistantName }: Props) {
const [open, setOpen] = useState(false);
return (
@@ -18,31 +19,36 @@ export function AssistantBubble({ configured, userId }: Props) {
<div
className="assistant-bubble-panel"
role="dialog"
aria-label="Assistant"
aria-label={assistantName}
aria-modal="false"
>
<div className="assistant-bubble-header">
<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>
<button
type="button"
className="assistant-bubble-close"
aria-label="Close assistant"
aria-label={`Close ${assistantName}`}
onClick={() => setOpen(false)}
>
<X className="size-4" />
</button>
</div>
<AssistantPanel key={userId} configured={configured} userId={userId} />
<AssistantPanel
key={userId}
configured={configured}
userId={userId}
assistantName={assistantName}
/>
</div>
) : null}
<button
type="button"
className="assistant-bubble-trigger"
aria-label={open ? "Close assistant" : "Open assistant"}
aria-label={open ? `Close ${assistantName}` : `Open ${assistantName}`}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
+160 -18
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Loader2, Send } from "lucide-react";
import { ImagePlus, Loader2, Mic, Send, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { consumeAgentChatStream } from "../assistant-chat-stream";
@@ -9,22 +9,53 @@ import {
clearAssistantChat,
loadAssistantChat,
saveAssistantChat,
toClientChatMessage,
type AssistantChatMessage,
} from "../assistant-chat-storage";
import { useVoiceInput } from "./use-voice-input";
type Props = {
configured: boolean;
userId: string;
assistantName: string;
};
export function AssistantPanel({ configured, userId }: Props) {
type PendingImage = {
url: string;
};
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 }: Props) {
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
const [input, setInput] = useState("");
const [pendingImage, setPendingImage] = useState<PendingImage | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [activityLabel, setActivityLabel] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(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(() => {
saveAssistantChat(userId, messages);
@@ -46,22 +77,50 @@ export function AssistantPanel({ configured, userId }: Props) {
function clearChat() {
abortRef.current?.abort();
setMessages([]);
setInput("");
setPendingImage(null);
setError(null);
setActivityLabel(null);
setIsPending(false);
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);
}
}
async function sendMessage() {
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("");
setPendingImage(null);
setError(null);
setMessages(nextMessages);
setIsPending(true);
setActivityLabel("Understanding your request…");
setActivityLabel(hasImage ? "Reading your photo…" : "Understanding your request…");
scrollToBottom();
abortRef.current?.abort();
@@ -72,7 +131,10 @@ export function AssistantPanel({ configured, userId }: Props) {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages, stream: true }),
body: JSON.stringify({
messages: nextMessages.map(toClientChatMessage),
stream: true,
}),
signal: controller.signal,
});
@@ -87,7 +149,10 @@ export function AssistantPanel({ configured, userId }: Props) {
throw new Error("Assistant returned an empty response");
}
setMessages((current) => [...current, result.message]);
setMessages((current) => [
...current,
{ role: "assistant", content: result.message.content },
]);
scrollToBottom();
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
@@ -100,13 +165,19 @@ export function AssistantPanel({ configured, userId }: Props) {
}
const showEmptyState = messages.length === 0 && !isPending;
const inputDisabled = isPending || voiceState === "transcribing" || uploadingImage;
const canSend =
!isPending &&
voiceState === "idle" &&
!uploadingImage &&
(input.trim().length > 0 || pendingImage !== null);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<p className="muted min-w-0 text-[12px] leading-relaxed">
{configured
? "Ask me to update lists, calendar, notes, or journal."
? "Type, talk, or send a photo — I can update lists, calendar, notes, and more."
: "Mock provider active — set LLM_BASE_URL for your homelab model."}
</p>
{messages.length > 0 ? (
@@ -128,8 +199,8 @@ export function AssistantPanel({ configured, userId }: Props) {
>
{showEmptyState ? (
<p className="muted text-[12px]">
Try &quot;add milk to the shopping list&quot; or &quot;what&apos;s on the calendar this
week?&quot;
Try &quot;add milk to the shopping list&quot;, tap the mic, or attach a photo of an
appointment card.
</p>
) : (
<div className="grid gap-2">
@@ -144,8 +215,16 @@ export function AssistantPanel({ configured, userId }: Props) {
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
>
<div className="eyebrow mb-0.5 text-[10px]">
{message.role === "user" ? "You" : "Assistant"}
{message.role === "user" ? "You" : assistantName}
</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}
</div>
))}
@@ -157,7 +236,7 @@ export function AssistantPanel({ configured, userId }: Props) {
aria-live="polite"
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">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{activityLabel ?? "Working…"}</span>
@@ -168,24 +247,87 @@ export function AssistantPanel({ configured, userId }: Props) {
)}
</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}
<form
className="flex gap-2"
onSubmit={(event) => {
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
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask the assistant…"
disabled={isPending}
aria-label="Assistant message"
className="h-9"
placeholder={
voiceState === "recording"
? "Listening… tap mic to stop"
: 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" />}
</Button>
</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 };
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
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 clientChatInputSchema = z.object({
stream: z.boolean().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,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)));
}
+33 -15
View File
@@ -1,16 +1,14 @@
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
import { buildVisionContentParts, textFromMessageContent } from "@/lib/llm/content";
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools";
import type { ClientChatMessage } from "../messages";
import { describeToolActivity } from "../tool-labels";
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
import { resolveAssistantImageDataUrls } from "./resolve-images";
import { thinkingLabel, type AgentProgressEvent } from "./progress";
const MAX_TOOL_ROUNDS = 8;
export type ClientChatMessage = {
role: "user" | "assistant";
content: string;
};
export type AgentToolCallSummary = {
name: string;
status: number;
@@ -23,9 +21,30 @@ export type AgentChatResult = {
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),
};
}
export async function runAgentChat(options: {
messages: ClientChatMessage[];
request: Request;
systemPrompt?: string;
llm?: LlmClient;
executeTool?: ToolExecutor;
onProgress?: AgentProgressHandler;
@@ -33,16 +52,13 @@ export async function runAgentChat(options: {
const llm = options.llm ?? createLlmClient();
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
const onProgress = options.onProgress;
const systemPrompt = options.systemPrompt ?? AGENT_SYSTEM_PROMPT;
const transcript: ChatMessage[] = [
{ role: "system", content: AGENT_SYSTEM_PROMPT },
...options.messages.map(
(message): ChatMessage => ({
role: message.role,
content: message.content,
}),
),
];
const userMessages = await Promise.all(
options.messages.map((message) => toLlmUserMessage(message)),
);
const transcript: ChatMessage[] = [{ role: "system", content: systemPrompt }, ...userMessages];
const toolCalls: AgentToolCallSummary[] = [];
@@ -62,7 +78,9 @@ export async function runAgentChat(options: {
return {
message: {
role: "assistant",
content: assistantMessage.content?.trim() || "I couldn't generate a response.",
content:
textFromMessageContent(assistantMessage.content).trim() ||
"I couldn't generate a response.",
},
toolCalls,
};
+2
View File
@@ -766,6 +766,8 @@ When no dedicated tool fits, or you are unsure how to do something:
1. Call get_api_docs with a relevant search term to find the right /api/v1/* endpoint.
2. Call call_api with the documented method, path, query, and body.
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. 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.
+1 -1
View File
@@ -16,7 +16,7 @@ test("assistant chat smoke after opt-in", async ({ page }) => {
await page.getByRole("button", { name: "Open assistant" }).click();
await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible();
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 expect(page.getByText("hello assistant")).toBeVisible();
+12
View File
@@ -71,4 +71,16 @@ describe("runAgentChat", () => {
assert.equal(result.toolCalls[0]?.name, "add_list_item");
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);
});
});
+28
View File
@@ -0,0 +1,28 @@
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);
});
});
+23
View File
@@ -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;
});
});
+12
View File
@@ -4,6 +4,7 @@ import {
clearAssistantChat,
loadAssistantChat,
saveAssistantChat,
toClientChatMessage,
} from "../../src/modules/agent/assistant-chat-storage";
const storage = new Map<string, string>();
@@ -49,4 +50,15 @@ describe("assistant chat storage", () => {
clearAssistantChat("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" },
]);
});
});
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
DEFAULT_ASSISTANT_NAME,
resolveAssistantSystemPrompt,
} from "../../src/lib/assistant-config";
import { AGENT_SYSTEM_PROMPT } from "../../src/modules/agent/tools";
describe("resolveAssistantSystemPrompt", () => {
it("returns the default prompt when custom is null", () => {
assert.equal(resolveAssistantSystemPrompt(null), AGENT_SYSTEM_PROMPT);
});
it("returns the default prompt when custom is blank", () => {
assert.equal(resolveAssistantSystemPrompt(" "), AGENT_SYSTEM_PROMPT);
});
it("returns trimmed custom prompt when set", () => {
assert.equal(resolveAssistantSystemPrompt(" Be extra cheerful. "), "Be extra cheerful.");
});
});
describe("DEFAULT_ASSISTANT_NAME", () => {
it("is Assistant", () => {
assert.equal(DEFAULT_ASSISTANT_NAME, "Assistant");
});
});
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildVisionContentParts, textFromMessageContent } from "../../src/lib/llm/content";
describe("llm content helpers", () => {
it("reads plain string content", () => {
assert.equal(textFromMessageContent("hello"), "hello");
});
it("joins text parts from multimodal content", () => {
assert.equal(
textFromMessageContent([
{ type: "text", text: "first" },
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
{ type: "text", text: "second" },
]),
"first\nsecond",
);
});
it("builds vision parts with fallback prompt when text is empty", () => {
const parts = buildVisionContentParts("", ["data:image/jpeg;base64,abc"]);
assert.equal(parts.length, 2);
assert.equal(parts[0]?.type, "text");
assert.equal(parts[1]?.type, "image_url");
});
});