diff --git a/drizzle/0024_user_assistant_customization.sql b/drizzle/0024_user_assistant_customization.sql new file mode 100644 index 0000000..e28608f --- /dev/null +++ b/drizzle/0024_user_assistant_customization.sql @@ -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; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 04f9b73..229b5ad 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1780393000000, "tag": "0023_comments", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1780394000000, + "tag": "0024_user_assistant_customization", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/agent/chat/route.ts b/src/app/api/agent/chat/route.ts index 9a904f1..287c62a 100644 --- a/src/app/api/agent/chat/route.ts +++ b/src/app/api/agent/chat/route.ts @@ -1,7 +1,7 @@ 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 { encodeSseEvent } from "@/modules/agent/server/progress"; import { runAgentChat } from "@/modules/agent/server/run"; @@ -25,11 +25,13 @@ export async function POST(request: Request) { 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(); @@ -54,6 +56,7 @@ export async function POST(request: Request) { const result = await runAgentChat({ messages: parsed.data.messages, request, + systemPrompt, onProgress: send, }); @@ -87,6 +90,7 @@ export async function POST(request: Request) { const result = await runAgentChat({ messages: parsed.data.messages, request, + systemPrompt, }); return apiJson({ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e91dc60..ef104b1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 {signedIn && assistantEnabled && session?.user?.id ? ( - + ) : null} diff --git a/src/app/settings/assistant-actions.ts b/src/app/settings/assistant-actions.ts index ea923ef..0adfe98 100644 --- a/src/app/settings/assistant-actions.ts +++ b/src/app/settings/assistant-actions.ts @@ -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 { 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 { + 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 { + 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 { + await setAssistantName(DEFAULT_ASSISTANT_NAME); +} + +export async function resetAssistantSystemPrompt(): Promise { + await setAssistantSystemPrompt(null); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index c0b3213..a495932 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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({ - + diff --git a/src/components/assistant-opt-in.tsx b/src/components/assistant-opt-in.tsx deleted file mode 100644 index 50a4621..0000000 --- a/src/components/assistant-opt-in.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/components/assistant-settings.tsx b/src/components/assistant-settings.tsx new file mode 100644 index 0000000..ec0ccb0 --- /dev/null +++ b/src/components/assistant-settings.tsx @@ -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 ( +
+ + +
+
+
+ +

Shown in the chat bubble and message labels.

+
+ +
+ setDraftName(event.target.value)} + aria-label="Assistant name" + /> + +
+ +
+
+
+ +

+ Instructions sent to the model before each conversation. Customize tone, priorities, + or household context. +

+
+ +
+