feat(aichat): add user-level custom system prompts (#6884)

* feat(aichat): create reusable CustomAIPrompts component

Extract custom AI prompts UI into a reusable component that can be
used in both workspace settings and user settings. Component includes:
- AI mode selector with visual indicators for set prompts
- Textarea with character limit
- Customizable title, description, and hint messages

Co-authored-by: centdix <centdix@users.noreply.github.com>

* refactor(aichat): update workspace AISettings to use reusable component

Replace inline custom prompts UI with the reusable CustomAIPrompts
component. Add hint about user-level custom prompts being available
in account settings and how they combine with workspace prompts.

Co-authored-by: centdix <centdix@users.noreply.github.com>

* feat(aichat): add user-level custom AI prompts in account settings

Add collapsible section in user settings for custom AI prompts:
- Stored in localStorage (key: userCustomAIPrompts)
- Collapsible UI to save space
- Visual indicator when prompts are configured
- Hint about prompt combination with workspace settings
- Prompts apply across all workspaces for the user

Co-authored-by: centdix <centdix@users.noreply.github.com>

* feat(aichat): combine workspace and user custom prompts

Update AIChatManager to combine workspace-level and user-level custom
prompts. Prompts are combined in order: workspace first, then user.

Add helper functions in aiStore.ts:
- getUserCustomPrompts(): retrieves user prompts from localStorage
- getCombinedCustomPrompt(mode): combines workspace + user prompts

All AI modes (script, flow, navigator, ask, API) now use combined
prompts, allowing users to append their own instructions to workspace
settings across all workspaces.

Co-authored-by: centdix <centdix@users.noreply.github.com>

* fix: remove unused imports

Remove unused imports to fix svelte-check errors:
- Remove unused 'get' from svelte/store in AIChatManager
- Remove unused 'copilotInfo' from aiStore in AIChatManager
- Remove unused 'AIMode' from AISettings

Co-authored-by: centdix <centdix@users.noreply.github.com>

* simplify

* nit

* fix

* fix

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: centdix <centdix@users.noreply.github.com>
This commit is contained in:
centdix
2025-10-23 08:35:51 +00:00
committed by GitHub
co-authored by centdix claude[bot]
parent 877af9c845
commit b5d0f23f4f
6 changed files with 169 additions and 76 deletions
+29
View File
@@ -4,6 +4,8 @@ import { type AIProviderModel, type AIProvider, WorkspaceService, type AIConfig
import { COPILOT_SESSION_MODEL_SETTING_NAME, COPILOT_SESSION_PROVIDER_SETTING_NAME } from './stores'
import { getLocalSetting } from './utils'
const USER_CUSTOM_PROMPTS_KEY = 'userCustomAIPrompts'
const sessionModel = getLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME)
const sessionProvider = getLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME)
export const copilotSessionModel = writable<AIProviderModel | undefined>(
@@ -90,3 +92,30 @@ export function getCurrentModel() {
}
return model
}
export function getUserCustomPrompts(): Record<string, string> {
const stored = getLocalSetting(USER_CUSTOM_PROMPTS_KEY)
if (stored) {
try {
return JSON.parse(stored)
} catch (e) {
console.error('Failed to parse user custom prompts', e)
return {}
}
}
return {}
}
export function getCombinedCustomPrompt(mode: string): string | undefined {
const workspacePrompt = get(copilotInfo).customPrompts?.[mode]
const userPrompts = getUserCustomPrompts()
const userPrompt = userPrompts[mode]
const prompts = [workspacePrompt, userPrompt].filter((p) => p?.trim())
if (prompts.length === 0) {
return undefined
}
return prompts.join('\n\n')
}
@@ -7,6 +7,7 @@
import { createEventDispatcher } from 'svelte'
import UserInfoSettings from './settings/UserInfoSettings.svelte'
import AIUserSettings from './settings/AIUserSettings.svelte'
import UserAIPromptsSettings from './settings/UserAIPromptsSettings.svelte'
interface Props {
scopes?: string[] | undefined
@@ -69,6 +70,7 @@
</div>
<div class="min-w-0">
<AIUserSettings />
<UserAIPromptsSettings />
</div>
</div>
{/if}
@@ -0,0 +1,69 @@
<script lang="ts">
import { AIMode } from './chat/AIChatManager.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import Label from '../Label.svelte'
import autosize from '$lib/autosize'
const MAX_CUSTOM_PROMPT_LENGTH = 5000
let {
customPrompts = $bindable(),
title,
description
}: {
customPrompts: Record<string, string>
title?: string
description?: string
} = $props()
let selectedAiMode = $state<AIMode>(AIMode.ASK)
</script>
<div class="flex flex-col gap-2">
<p class="font-semibold">{title}</p>
{#if description}
<p class="text-xs text-secondary">{description}</p>
{/if}
<div class="flex flex-col gap-4">
<Label label="AI Mode">
<ToggleButtonGroup bind:selected={selectedAiMode}>
{#snippet children({ item })}
{#each Object.values(AIMode) as mode}
<div class="relative">
<ToggleButton
value={mode}
label={mode.charAt(0).toUpperCase() + mode.slice(1)}
{item}
/>
{#if customPrompts[mode]?.length > 0}
<div
class="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full border border-surface"
></div>
{/if}
</div>
{/each}
{/snippet}
</ToggleButtonGroup>
</Label>
<Label
label="Custom system prompt for {selectedAiMode.charAt(0).toUpperCase() +
selectedAiMode.slice(1)} Mode"
>
<textarea
bind:value={customPrompts[selectedAiMode]}
placeholder="Enter a custom system prompt for {selectedAiMode} mode."
class="w-full min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-md bg-surface text-primary resize-y"
rows="4"
maxlength={MAX_CUSTOM_PROMPT_LENGTH}
use:autosize
></textarea>
<div class="flex justify-end mt-1">
<span class="text-xs text-secondary">
{(customPrompts[selectedAiMode] ?? '').length}/{MAX_CUSTOM_PROMPT_LENGTH} characters
</span>
</div>
</Label>
</div>
</div>
@@ -35,7 +35,6 @@ import { getStringError } from './utils'
import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState'
import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types'
import { untrack } from 'svelte'
import { get } from 'svelte/store'
import { type DBSchemas } from '$lib/stores'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
@@ -45,7 +44,7 @@ import type AIChatInput from './AIChatInput.svelte'
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
import type { ReviewChangesOpts } from './monaco-adapter'
import { copilotInfo, getCurrentModel } from '$lib/aiStore'
import { getCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
@@ -127,7 +126,7 @@ class AIChatManager {
return (
estimatedTokens >
modelContextWindow -
Math.max(modelContextWindow * MAX_TOKENS_THRESHOLD_PERCENTAGE, MAX_TOKENS_HARD_LIMIT)
Math.max(modelContextWindow * MAX_TOKENS_THRESHOLD_PERCENTAGE, MAX_TOKENS_HARD_LIMIT)
)
}
@@ -209,7 +208,7 @@ class AIChatManager {
this.mode = mode
this.pendingPrompt = pendingPrompt ?? ''
if (mode === AIMode.SCRIPT) {
const customPrompt = get(copilotInfo).customPrompts?.[mode]
const customPrompt = getCombinedCustomPrompt(mode)
const currentModel = getCurrentModel()
this.systemMessage = prepareScriptSystemMessage(currentModel, customPrompt)
this.systemMessage.content = this.NAVIGATION_SYSTEM_PROMPT + this.systemMessage.content
@@ -236,23 +235,23 @@ class AIChatManager {
}
}
} else if (mode === AIMode.FLOW) {
const customPrompt = get(copilotInfo).customPrompts?.[mode]
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareFlowSystemMessage(customPrompt)
this.systemMessage.content = this.NAVIGATION_SYSTEM_PROMPT + this.systemMessage.content
this.tools = [this.changeModeTool, ...flowTools]
this.helpers = this.flowAiChatHelpers
} else if (mode === AIMode.NAVIGATOR) {
const customPrompt = get(copilotInfo).customPrompts?.[mode]
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareNavigatorSystemMessage(customPrompt)
this.tools = [this.changeModeTool, ...navigatorTools]
this.helpers = {}
} else if (mode === AIMode.ASK) {
const customPrompt = get(copilotInfo).customPrompts?.[mode]
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareAskSystemMessage(customPrompt)
this.tools = [...askTools]
this.helpers = {}
} else if (mode === AIMode.API) {
const customPrompt = get(copilotInfo).customPrompts?.[mode]
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareApiSystemMessage(customPrompt)
this.tools = [...this.apiTools]
this.helpers = {}
@@ -480,8 +479,8 @@ class AIChatManager {
onNewToken: (token: string) => {
reply += token
},
onMessageEnd: () => { },
setToolStatus: () => { }
onMessageEnd: () => {},
setToolStatus: () => {}
},
systemMessage
}
@@ -887,15 +886,15 @@ class AIChatManager {
const editorRelated =
currentEditor && currentEditor.type === 'script' && currentEditor.stepId === module.id
? {
diffMode: currentEditor.diffMode,
lastDeployedCode: currentEditor.lastDeployedCode,
lastSavedCode: undefined
}
diffMode: currentEditor.diffMode,
lastDeployedCode: currentEditor.lastDeployedCode,
lastSavedCode: undefined
}
: {
diffMode: false,
lastDeployedCode: undefined,
lastSavedCode: undefined
}
diffMode: false,
lastDeployedCode: undefined,
lastSavedCode: undefined
}
return {
args: moduleState?.previewArgs ?? {},
@@ -0,0 +1,50 @@
<script lang="ts">
import { storeLocalSetting } from '$lib/utils'
import CustomAIPrompts from '../copilot/CustomAIPrompts.svelte'
import Button from '../common/button/Button.svelte'
import { ChevronDown, ChevronRight } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { getUserCustomPrompts } from '$lib/aiStore'
const USER_CUSTOM_PROMPTS_KEY = 'userCustomAIPrompts'
let customPrompts = $state<Record<string, string>>(getUserCustomPrompts())
let isExpanded = $state(false)
function save() {
storeLocalSetting(USER_CUSTOM_PROMPTS_KEY, JSON.stringify(customPrompts))
sendUserToast('User AI prompts saved')
}
let hasPrompts = $derived(Object.values(customPrompts).some((p) => p?.trim().length > 0))
</script>
<div class="mt-4">
<button
type="button"
class="flex items-center border-b cursor-pointer hover:bg-surface-hover w-full transition-colors"
onclick={() => (isExpanded = !isExpanded)}
>
{#if isExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
<h2>Custom system prompts</h2>
{#if hasPrompts}
<div class="w-2 h-2 bg-blue-500 rounded-full ml-2"></div>
{/if}
</button>
{#if isExpanded}
<div>
<CustomAIPrompts
bind:customPrompts
description="These prompts are stored locally in your browser and apply in addition to the workspace-level prompts."
/>
<div class="flex flex-row justify-end mt-2">
<Button onclick={save}>Save</Button>
</div>
</div>
{/if}
</div>
@@ -14,14 +14,9 @@
import { safeSelectItems } from '../select/utils.svelte'
import Badge from '../common/badge/Badge.svelte'
import Tooltip from '../Tooltip.svelte'
import { AIMode } from '../copilot/chat/AIChatManager.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import autosize from '$lib/autosize'
import ModelTokenLimits from './ModelTokenLimits.svelte'
import { setCopilotInfo } from '$lib/aiStore'
const MAX_CUSTOM_PROMPT_LENGTH = 5000
import CustomAIPrompts from '../copilot/CustomAIPrompts.svelte'
let {
aiProviders = $bindable(),
@@ -48,9 +43,6 @@
) as Record<AIProvider, string[]>
)
// Custom system prompt settings
let selectedAiMode = $state<AIMode>(AIMode.ASK)
let selectedAiModels = $derived(Object.values(aiProviders).flatMap((p) => p.models))
let modelProviderMap = $derived(
Object.fromEntries(
@@ -331,55 +323,7 @@
{/if}
{#if Object.keys(aiProviders).length > 0}
<div class="flex flex-col gap-2">
<p class="font-semibold">Custom system prompts</p>
<div class="flex flex-col gap-4">
<Label label="AI Mode">
<ToggleButtonGroup
bind:selected={selectedAiMode}
on:selected={({ detail }) => {
selectedAiMode = detail
}}
>
{#snippet children({ item })}
{#each Object.values(AIMode) as mode}
<div class="relative">
<ToggleButton
value={mode}
label={mode.charAt(0).toUpperCase() + mode.slice(1)}
{item}
/>
{#if customPrompts[mode]?.length > 0}
<div
class="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full border border-surface"
></div>
{/if}
</div>
{/each}
{/snippet}
</ToggleButtonGroup>
</Label>
<Label
label="Custom system prompt for {selectedAiMode.charAt(0).toUpperCase() +
selectedAiMode.slice(1)} Mode"
>
<textarea
bind:value={customPrompts[selectedAiMode]}
placeholder="Enter a custom system prompt for {selectedAiMode} mode."
class="w-full min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-md bg-surface text-primary resize-y"
rows="4"
maxlength={MAX_CUSTOM_PROMPT_LENGTH}
use:autosize
></textarea>
<div class="flex justify-end mt-1">
<span class="text-xs text-secondary">
{(customPrompts[selectedAiMode] ?? '').length}/{MAX_CUSTOM_PROMPT_LENGTH} characters
</span>
</div>
</Label>
</div>
</div>
<CustomAIPrompts bind:customPrompts title="Custom system prompts" />
{/if}
<Button