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.
This commit is contained in:
ginnoir
2026-07-04 23:26:17 -05:00
parent a2e5eb111d
commit e5e509081c
17 changed files with 370 additions and 59 deletions
+7 -3
View File
@@ -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({
+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>
</>