Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Added a per-user Chat Preferences settings page where users can tune the chat agent's response style across six dimensions (depth, code visibility, vocabulary, citation density, output structure, diagrams) plus a free-text custom-instructions field, applied as soft biases to the agent's system prompt. [#1243](https://github.com/sourcebot-dev/sourcebot/pull/1243)

### Changed
- Documented session lifetime, repository visibility refresh behavior, and how permission sync handles transient code-host errors. [#1218](https://github.com/sourcebot-dev/sourcebot/pull/1218)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "chatCustomInstructions" TEXT,
ADD COLUMN "chatPreferences" JSONB NOT NULL DEFAULT '{}';
13 changes: 13 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,19 @@ model User {
/// claim baked into the JWT cookie at mint time.
sessionVersion Int @default(0)

/// Per-user chat response style preferences. JSON object keyed by dimension
/// (depth, codeVisibility, vocabulary, citationDensity, outputStructure,
/// diagrams). Each value is a 3-level enum string (see
/// `packages/web/src/features/chat/userPreferences.ts`). An empty object
/// means no preferences have been set and the agent uses default behavior.
chatPreferences Json @default("{}")

/// Free-text custom instructions appended to the agent's system prompt for
/// every chat owned by this user. Length is capped at the application
/// layer (see `chatCustomInstructionsSchema`). Null means no custom
/// instructions.
chatCustomInstructions String?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/__mocks__/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = {
emailVerified: null,
image: null,
sessionVersion: 0,
chatPreferences: {},
chatCustomInstructions: null,
accounts: [],
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
'use client';

import { useToast } from "@/components/hooks/use-toast";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { LoadingButton } from "@/components/ui/loading-button";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { updateChatPreferences } from "@/features/chat/actions";
import {
CHAT_CUSTOM_INSTRUCTIONS_MAX_LENGTH,
CHAT_PREFERENCE_SPEC,
ChatPreferenceDimension,
ChatPreferences,
VISIBLE_CHAT_PREFERENCE_DIMENSIONS,
} from "@/features/chat/userPreferences";
import { isServiceError } from "@/lib/utils";
import { useCallback, useMemo, useState } from "react";

interface ChatPreferencesPageProps {
initialPreferences: ChatPreferences;
initialCustomInstructions: string | null;
}

export function ChatPreferencesPage({
initialPreferences,
initialCustomInstructions,
}: ChatPreferencesPageProps) {
const { toast } = useToast();
const captureEvent = useCaptureEvent();

const [preferences, setPreferences] = useState<ChatPreferences>(initialPreferences);
const [customInstructions, setCustomInstructions] = useState<string>(initialCustomInstructions ?? "");
const [isSaving, setIsSaving] = useState(false);

// Compare current form state against the last-saved baseline to enable/disable
// the save button and reset link.
const [savedSnapshot, setSavedSnapshot] = useState({
preferences: initialPreferences,
customInstructions: initialCustomInstructions ?? "",
});

const hasUnsavedChanges = useMemo(() => {
if (customInstructions !== savedSnapshot.customInstructions) {
return true;
}
const currentKeys = Object.keys(preferences) as ChatPreferenceDimension[];
const savedKeys = Object.keys(savedSnapshot.preferences) as ChatPreferenceDimension[];
if (currentKeys.length !== savedKeys.length) {
return true;
}
for (const k of currentKeys) {
if (preferences[k] !== savedSnapshot.preferences[k]) {
return true;
}
}
return false;
}, [preferences, customInstructions, savedSnapshot]);

const isOverLimit = customInstructions.length > CHAT_CUSTOM_INSTRUCTIONS_MAX_LENGTH;

const handleDimensionChange = useCallback((dimension: ChatPreferenceDimension, value: string) => {
setPreferences((prev) => {
const next: Record<string, string> = { ...prev };
if (value === "") {
delete next[dimension];
} else {
// The string value originates from a ToggleGroupItem whose `value`
// attribute is one of this dimension's level values; the write
// path then validates the full object against `chatPreferencesSchema`.
next[dimension] = value;
}
return next as ChatPreferences;
});
}, []);

const handleReset = useCallback(() => {
setPreferences(savedSnapshot.preferences);
setCustomInstructions(savedSnapshot.customInstructions);
}, [savedSnapshot]);

const handleSave = useCallback(async () => {
if (isOverLimit) {
return;
}

setIsSaving(true);
try {
const trimmedCustom = customInstructions.trim();
const result = await updateChatPreferences({
preferences,
customInstructions: trimmedCustom.length > 0 ? trimmedCustom : null,
});

if (isServiceError(result)) {
toast({
title: "Failed to save chat preferences",
description: result.message,
variant: "destructive",
});
return;
}

setSavedSnapshot({
preferences,
customInstructions,
});
Comment on lines +103 to +106
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize saved custom instructions to persisted value.

Line 103 stores customInstructions as typed, but Line 91 persists trimmedCustom (or null). That can make “Discard changes” restore text that was never actually stored.

Proposed fix
             if (isServiceError(result)) {
                 toast({
                     title: "Failed to save chat preferences",
                     description: result.message,
                     variant: "destructive",
                 });
                 return;
             }

+            const normalizedCustomInstructions = trimmedCustom.length > 0 ? trimmedCustom : "";
+            setCustomInstructions(normalizedCustomInstructions);
             setSavedSnapshot({
                 preferences,
-                customInstructions,
+                customInstructions: normalizedCustomInstructions,
             });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setSavedSnapshot({
preferences,
customInstructions,
});
if (isServiceError(result)) {
toast({
title: "Failed to save chat preferences",
description: result.message,
variant: "destructive",
});
return;
}
const normalizedCustomInstructions = trimmedCustom.length > 0 ? trimmedCustom : "";
setCustomInstructions(normalizedCustomInstructions);
setSavedSnapshot({
preferences,
customInstructions: normalizedCustomInstructions,
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/app/`(app)/settings/chatPreferences/chatPreferencesPage.tsx
around lines 103 - 106, The saved snapshot is storing the raw
customInstructions, but the persisted value uses trimmedCustom (or null),
causing discard to restore an unpersisted state; update the setSavedSnapshot
call inside the save flow to store the normalized value (use the trimmedCustom
variable or null) instead of customInstructions so the snapshot matches what is
actually persisted (refer to setSavedSnapshot, customInstructions, and
trimmedCustom).

captureEvent("wa_chat_preferences_saved", {
dimensionsSet: Object.keys(preferences).length,
hasCustomInstructions: trimmedCustom.length > 0,
customInstructionsLength: trimmedCustom.length,
});
toast({
title: "Chat preferences saved",
description: "Sourcebot will apply these to future chats.",
});
} catch (error) {
toast({
title: "Failed to save chat preferences",
description: error instanceof Error ? error.message : String(error),
variant: "destructive",
});
} finally {
setIsSaving(false);
}
}, [preferences, customInstructions, isOverLimit, toast, captureEvent]);

return (
<div className="flex flex-col gap-8">
<div>
<h3 className="text-lg font-medium">Chat Preferences</h3>
<p className="text-sm text-muted-foreground max-w-xl">
Tune how Sourcebot writes its answers. These preferences are applied as soft
biases to every chat you start. They never override the explicit content of
your message, and you can leave any row unset to keep the default behavior.
</p>
</div>

<div className="flex flex-col gap-6">
{VISIBLE_CHAT_PREFERENCE_DIMENSIONS.map((dimension) => {
const spec = CHAT_PREFERENCE_SPEC[dimension];
const currentValue = preferences[dimension] ?? "";
return (
<div key={dimension} className="flex flex-col gap-2">
<div>
<h4 className="text-sm font-medium">{spec.label}</h4>
<p className="text-sm text-muted-foreground">
{spec.description}
</p>
</div>
<ToggleGroup
type="single"
value={currentValue}
onValueChange={(value) => handleDimensionChange(dimension, value)}
variant="outline"
className="flex-wrap justify-start gap-2"
aria-label={spec.label}
>
{spec.levels.map((level) => (
<ToggleGroupItem
key={level.value}
value={level.value}
aria-label={`${spec.label}: ${level.label}`}
className="h-9 w-auto min-w-0 px-3"
>
{level.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
})}

<div className="flex flex-col gap-2">
<div>
<h4 className="text-sm font-medium">Custom instructions</h4>
<p className="text-sm text-muted-foreground">
Anything else you want Sourcebot to keep in mind when answering.
Used as soft guidance, never as an override.
</p>
</div>
<Textarea
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
placeholder={
"e.g. \"I'm a PM, not an engineer. Skip implementation details and " +
"focus on what the feature does for end users.\""
}
maxLength={CHAT_CUSTOM_INSTRUCTIONS_MAX_LENGTH}
className="min-h-[120px]"
aria-label="Custom instructions"
/>
<div
className={
isOverLimit
? "text-xs text-destructive self-end"
: "text-xs text-muted-foreground self-end"
}
>
{customInstructions.length} / {CHAT_CUSTOM_INSTRUCTIONS_MAX_LENGTH}
</div>
</div>
</div>

<div className="flex flex-row gap-2 justify-end items-center">
{hasUnsavedChanges && (
<button
type="button"
onClick={handleReset}
className="text-sm text-muted-foreground hover:text-foreground underline-offset-4 hover:underline"
disabled={isSaving}
>
Discard changes
</button>
)}
<LoadingButton
onClick={handleSave}
loading={isSaving}
disabled={!hasUnsavedChanges || isOverLimit}
>
Save changes
</LoadingButton>
</div>
</div>
);
}
19 changes: 19 additions & 0 deletions packages/web/src/app/(app)/settings/chatPreferences/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getChatPreferences } from "@/features/chat/actions";
import { authenticatedPage } from "@/middleware/authenticatedPage";
import { isServiceError } from "@/lib/utils";
import { ServiceErrorException } from "@/lib/serviceError";
import { ChatPreferencesPage } from "./chatPreferencesPage";

export default authenticatedPage(async () => {
const result = await getChatPreferences();
if (isServiceError(result)) {
throw new ServiceErrorException(result);
}

return (
<ChatPreferencesPage
initialPreferences={result.preferences}
initialCustomInstructions={result.customInstructions}
/>
);
});
4 changes: 4 additions & 0 deletions packages/web/src/app/(app)/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export const getSidebarNavItems = async () =>
href: `/settings/apiKeys`,
}
] : []),
{
title: "Chat Preferences",
href: `/settings/chatPreferences`,
},
...(role === OrgRole.OWNER ? [
{
title: "Analytics",
Expand Down
31 changes: 30 additions & 1 deletion packages/web/src/app/api/(server)/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { sew } from "@/middleware/sew";
import { createMessageStream } from "@/features/chat/agent";
import { createMessageStream, ResolvedChatUserPreferences } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
import { getLanguageModelKey } from "@/features/chat/utils";
import { getAISDKLanguageModelAndOptions, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
import { chatPreferencesSchema } from "@/features/chat/userPreferences";
import { apiHandler } from "@/lib/apiHandler";
import { ErrorCode } from "@/lib/errorCodes";
import { captureEvent } from "@/lib/posthog";
Expand Down Expand Up @@ -93,6 +94,33 @@ export const POST = apiHandler(async (req: NextRequest) => {

const source = req.headers.get('X-Sourcebot-Client-Source') ?? undefined;

// Load the user's chat-style preferences. Anonymous users skip this
// entirely so the agent uses default behavior with no
// `<user_preferences>` block in the system prompt.
let userPreferences: ResolvedChatUserPreferences | undefined;
if (user) {
const row = await prisma.user.findUnique({
where: { id: user.id },
select: {
chatPreferences: true,
chatCustomInstructions: true,
},
});
if (row) {
const parsed = chatPreferencesSchema.safeParse(row.chatPreferences);
// Cast back to the narrower literal-union map: the schema
// is built dynamically so its inferred type widens to
// `string`, but the runtime validation still constrains
// each value to its per-dimension level list.
userPreferences = {
preferences: parsed.success
? (parsed.data as ResolvedChatUserPreferences["preferences"])
: {},
customInstructions: row.chatCustomInstructions,
};
Comment on lines +110 to +120
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate and cap chatCustomInstructions before passing to the agent.

Line 113 forwards DB text as-is. If stored data is malformed/oversized, this bypasses the 1000-char contract and can unnecessarily bloat prompt tokens.

💡 Suggested fix
                 if (row) {
                     const parsed = chatPreferencesSchema.safeParse(row.chatPreferences);
+                    const customInstructions =
+                        typeof row.chatCustomInstructions === 'string' &&
+                        row.chatCustomInstructions.length <= 1000
+                            ? row.chatCustomInstructions
+                            : null;
                     userPreferences = {
                         preferences: parsed.success ? parsed.data : {},
-                        customInstructions: row.chatCustomInstructions,
+                        customInstructions,
                     };
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parsed = chatPreferencesSchema.safeParse(row.chatPreferences);
userPreferences = {
preferences: parsed.success ? parsed.data : {},
customInstructions: row.chatCustomInstructions,
};
const parsed = chatPreferencesSchema.safeParse(row.chatPreferences);
const customInstructions =
typeof row.chatCustomInstructions === 'string' &&
row.chatCustomInstructions.length <= 1000
? row.chatCustomInstructions
: null;
userPreferences = {
preferences: parsed.success ? parsed.data : {},
customInstructions,
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/app/api/`(server)/chat/route.ts around lines 110 - 114, The
DB value row.chatCustomInstructions is forwarded raw in route.ts when building
userPreferences; validate it is a string and enforce the 1000-character contract
(or a configured MAX_CUSTOM_INSTRUCTIONS constant) before using it. Use
chatPreferencesSchema.safeParse for preferences as done, then coerce/validate
chatCustomInstructions (e.g., ensure typeof === "string", trim, and truncate to
1000 chars) and assign that sanitized value to
userPreferences.customInstructions so oversized or malformed DB values cannot
bloat prompts.

}
}

await captureEvent('ask_message_sent', {
chatId: id,
messageCount: messages.length,
Expand All @@ -112,6 +140,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
modelProviderOptions: providerOptions,
modelTemperature: temperature,
userPreferences,
onFinish: async ({ messages }) => {
await updateChatMessages({ chatId: id, messages, prisma });
},
Expand Down
Loading