feat: per-user assistant name and system prompt customization
Each user can rename the AI assistant and edit their own system prompt in Settings.
This commit is contained in:
@@ -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;
|
||||||
@@ -169,6 +169,13 @@
|
|||||||
"when": 1780393000000,
|
"when": 1780393000000,
|
||||||
"tag": "0023_comments",
|
"tag": "0023_comments",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780394000000,
|
||||||
|
"tag": "0024_user_assistant_customization",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
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 { 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";
|
||||||
@@ -25,11 +25,13 @@ export async function POST(request: Request) {
|
|||||||
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();
|
||||||
@@ -54,6 +56,7 @@ 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,
|
||||||
onProgress: send,
|
onProgress: send,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +90,7 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
return apiJson({
|
return apiJson({
|
||||||
|
|||||||
+9
-1
@@ -18,6 +18,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";
|
||||||
@@ -102,6 +103,7 @@ 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;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
@@ -114,6 +116,7 @@ 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,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, session.user.id))
|
.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;
|
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;
|
||||||
}
|
}
|
||||||
userDashboards = await db
|
userDashboards = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -178,7 +182,11 @@ 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}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AppToaster position="bottom-right" />
|
<AppToaster position="bottom-right" />
|
||||||
</QuickAddProvider>
|
</QuickAddProvider>
|
||||||
|
|||||||
@@ -2,13 +2,58 @@
|
|||||||
|
|
||||||
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 { users } from "@/modules/_core/schema";
|
import { users } from "@/modules/_core/schema";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
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> {
|
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 resetAssistantName(): Promise<void> {
|
||||||
|
await setAssistantName(DEFAULT_ASSISTANT_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAssistantSystemPrompt(): Promise<void> {
|
||||||
|
await setAssistantSystemPrompt(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ 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 { 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";
|
||||||
@@ -322,6 +323,8 @@ function AppearanceSection({
|
|||||||
themeCalView: string;
|
themeCalView: string;
|
||||||
themeNavStyle: string;
|
themeNavStyle: string;
|
||||||
assistantEnabled: boolean;
|
assistantEnabled: boolean;
|
||||||
|
assistantName: string;
|
||||||
|
assistantSystemPrompt: string | null;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -355,7 +358,12 @@ 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}
|
||||||
|
systemPrompt={user.assistantSystemPrompt}
|
||||||
|
defaultSystemPrompt={AGENT_SYSTEM_PROMPT}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,40 @@
|
|||||||
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 { 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssistantEnabled(userId: string): Promise<boolean> {
|
||||||
|
const prefs = await getAssistantPreferences(userId);
|
||||||
|
return prefs.enabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ 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"),
|
||||||
defaultEventReminderOffsets: jsonb("default_event_reminder_offsets")
|
defaultEventReminderOffsets: jsonb("default_event_reminder_offsets")
|
||||||
.notNull()
|
.notNull()
|
||||||
.$type<number[]>()
|
.$type<number[]>()
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { AssistantPanel } from "./assistant-panel";
|
|||||||
type Props = {
|
type Props = {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
assistantName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AssistantBubble({ configured, userId }: Props) {
|
export function AssistantBubble({ configured, userId, assistantName }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,31 +19,36 @@ 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}
|
||||||
|
/>
|
||||||
</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)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
type Props = {
|
type Props = {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
assistantName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AssistantPanel({ configured, userId }: Props) {
|
export function AssistantPanel({ configured, userId, assistantName }: Props) {
|
||||||
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
|
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -144,7 +145,7 @@ 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.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
@@ -157,7 +158,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>
|
||||||
@@ -180,9 +181,9 @@ export function AssistantPanel({ configured, userId }: Props) {
|
|||||||
<Input
|
<Input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => setInput(event.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
placeholder="Ask the assistant…"
|
placeholder={`Ask ${assistantName}…`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
aria-label="Assistant message"
|
aria-label={`Message for ${assistantName}`}
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm" disabled={isPending || !input.trim()} aria-label="Send">
|
<Button type="submit" size="sm" disabled={isPending || !input.trim()} aria-label="Send">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type AgentProgressHandler = (event: AgentProgressEvent) => void;
|
|||||||
export async function runAgentChat(options: {
|
export async function runAgentChat(options: {
|
||||||
messages: ClientChatMessage[];
|
messages: ClientChatMessage[];
|
||||||
request: Request;
|
request: Request;
|
||||||
|
systemPrompt?: string;
|
||||||
llm?: LlmClient;
|
llm?: LlmClient;
|
||||||
executeTool?: ToolExecutor;
|
executeTool?: ToolExecutor;
|
||||||
onProgress?: AgentProgressHandler;
|
onProgress?: AgentProgressHandler;
|
||||||
@@ -33,9 +34,10 @@ export async function runAgentChat(options: {
|
|||||||
const llm = options.llm ?? createLlmClient();
|
const llm = options.llm ?? createLlmClient();
|
||||||
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
|
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
|
||||||
const onProgress = options.onProgress;
|
const onProgress = options.onProgress;
|
||||||
|
const systemPrompt = options.systemPrompt ?? AGENT_SYSTEM_PROMPT;
|
||||||
|
|
||||||
const transcript: ChatMessage[] = [
|
const transcript: ChatMessage[] = [
|
||||||
{ role: "system", content: AGENT_SYSTEM_PROMPT },
|
{ role: "system", content: systemPrompt },
|
||||||
...options.messages.map(
|
...options.messages.map(
|
||||||
(message): ChatMessage => ({
|
(message): ChatMessage => ({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ test("assistant chat smoke after opt-in", async ({ page }) => {
|
|||||||
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 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,16 @@ 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user