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
@@ -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, "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
} }
] ]
} }
+7 -3
View File
@@ -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
View File
@@ -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>
+47 -2
View File
@@ -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);
} }
+10 -2
View File
@@ -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>
</> </>
-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 { 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;
} }
+2
View File
@@ -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">
+3 -1
View File
@@ -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,
+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 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();
+12
View File
@@ -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);
});
}); });
+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");
});
});