User configurable templates (#6420)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-01-20 17:15:52 -05:00
committed by GitHub
parent 5a04ee8700
commit 2f083b827a
23 changed files with 1052 additions and 307 deletions
+210
View File
@@ -1014,6 +1014,140 @@
}
}
},
"/config/prompts": {
"get": {
"tags": [
"super::routes::prompts"
],
"operationId": "get_prompts",
"responses": {
"200": {
"description": "List of all available prompts",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PromptsListResponse"
}
}
}
}
}
}
},
"/config/prompts/{name}": {
"get": {
"tags": [
"super::routes::prompts"
],
"operationId": "get_prompt",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Prompt template name (e.g., system.md)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Prompt content retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PromptContentResponse"
}
}
}
},
"404": {
"description": "Prompt not found"
}
}
},
"put": {
"tags": [
"super::routes::prompts"
],
"operationId": "save_prompt",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Prompt template name (e.g., system.md)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SavePromptRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Prompt saved successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Prompt not found"
},
"500": {
"description": "Failed to save prompt"
}
}
},
"delete": {
"tags": [
"super::routes::prompts"
],
"operationId": "reset_prompt",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Prompt template name (e.g., system.md)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Prompt reset to default successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Prompt not found"
},
"500": {
"description": "Failed to reset prompt"
}
}
}
},
"/config/providers": {
"get": {
"tags": [
@@ -4691,6 +4825,43 @@
"Tool"
]
},
"PromptContentResponse": {
"type": "object",
"required": [
"name",
"content",
"default_content",
"is_customized"
],
"properties": {
"content": {
"type": "string"
},
"default_content": {
"type": "string"
},
"is_customized": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"PromptsListResponse": {
"type": "object",
"required": [
"prompts"
],
"properties": {
"prompts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Template"
}
}
}
},
"ProviderDetails": {
"type": "object",
"required": [
@@ -5352,6 +5523,17 @@
}
}
},
"SavePromptRequest": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"type": "string"
}
}
},
"SaveRecipeRequest": {
"type": "object",
"required": [
@@ -5970,6 +6152,34 @@
}
}
},
"Template": {
"type": "object",
"description": "Information about a template including its content and customization status",
"required": [
"name",
"description",
"default_content",
"is_customized"
],
"properties": {
"default_content": {
"type": "string"
},
"description": {
"type": "string"
},
"is_customized": {
"type": "boolean"
},
"name": {
"type": "string"
},
"user_content": {
"type": "string",
"nullable": true
}
}
},
"TextContent": {
"type": "object",
"required": [
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+134
View File
@@ -617,6 +617,17 @@ export type PricingResponse = {
export type PrincipalType = 'Extension' | 'Tool';
export type PromptContentResponse = {
content: string;
default_content: string;
is_customized: boolean;
name: string;
};
export type PromptsListResponse = {
prompts: Array<Template>;
};
export type ProviderDetails = {
is_configured: boolean;
metadata: ProviderMetadata;
@@ -857,6 +868,10 @@ export type RunNowResponse = {
session_id: string;
};
export type SavePromptRequest = {
content: string;
};
export type SaveRecipeRequest = {
id?: string | null;
recipe: Recipe;
@@ -1041,6 +1056,17 @@ export type TelemetryEventRequest = {
};
};
/**
* Information about a template including its content and customization status
*/
export type Template = {
default_content: string;
description: string;
is_customized: boolean;
name: string;
user_content?: string | null;
};
export type TextContent = {
_meta?: {
[key: string]: unknown;
@@ -2005,6 +2031,114 @@ export type GetPricingResponses = {
export type GetPricingResponse = GetPricingResponses[keyof GetPricingResponses];
export type GetPromptsData = {
body?: never;
path?: never;
query?: never;
url: '/config/prompts';
};
export type GetPromptsResponses = {
/**
* List of all available prompts
*/
200: PromptsListResponse;
};
export type GetPromptsResponse = GetPromptsResponses[keyof GetPromptsResponses];
export type ResetPromptData = {
body?: never;
path: {
/**
* Prompt template name (e.g., system.md)
*/
name: string;
};
query?: never;
url: '/config/prompts/{name}';
};
export type ResetPromptErrors = {
/**
* Prompt not found
*/
404: unknown;
/**
* Failed to reset prompt
*/
500: unknown;
};
export type ResetPromptResponses = {
/**
* Prompt reset to default successfully
*/
200: string;
};
export type ResetPromptResponse = ResetPromptResponses[keyof ResetPromptResponses];
export type GetPromptData = {
body?: never;
path: {
/**
* Prompt template name (e.g., system.md)
*/
name: string;
};
query?: never;
url: '/config/prompts/{name}';
};
export type GetPromptErrors = {
/**
* Prompt not found
*/
404: unknown;
};
export type GetPromptResponses = {
/**
* Prompt content retrieved successfully
*/
200: PromptContentResponse;
};
export type GetPromptResponse = GetPromptResponses[keyof GetPromptResponses];
export type SavePromptData = {
body: SavePromptRequest;
path: {
/**
* Prompt template name (e.g., system.md)
*/
name: string;
};
query?: never;
url: '/config/prompts/{name}';
};
export type SavePromptErrors = {
/**
* Prompt not found
*/
404: unknown;
/**
* Failed to save prompt
*/
500: unknown;
};
export type SavePromptResponses = {
/**
* Prompt saved successfully
*/
200: string;
};
export type SavePromptResponse = SavePromptResponses[keyof SavePromptResponses];
export type ProvidersData = {
body?: never;
path?: never;
+1 -2
View File
@@ -302,9 +302,8 @@ function BaseChatContent({
name: session?.name || 'No Session',
};
// Update the global chat context when session name changes
const lastSetNameRef = useRef<string>('');
useEffect(() => {
const currentSessionName = session?.name;
if (currentSessionName && currentSessionName !== lastSetNameRef.current) {
@@ -0,0 +1,297 @@
import { useState, useEffect } from 'react';
import {
getPrompt,
getPrompts,
PromptContentResponse,
Template,
resetPrompt,
savePrompt,
} from '../../api';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { AlertTriangle, RotateCcw, ArrowLeft } from 'lucide-react';
import { toast } from 'react-toastify';
export default function PromptsSettingsSection() {
const [prompts, setPrompts] = useState<Template[]>([]);
const [selectedPrompt, setSelectedPrompt] = useState<string | null>(null);
const [promptData, setPromptData] = useState<PromptContentResponse | null>(null);
const [content, setContent] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const fetchPrompts = async () => {
try {
const response = await getPrompts();
if (response.data) {
setPrompts(response.data.prompts);
}
} catch (error) {
console.error('Failed to fetch prompts:', error);
toast.error('Failed to load prompts');
}
};
useEffect(() => {
fetchPrompts();
}, []);
useEffect(() => {
if (selectedPrompt) {
const fetchPrompt = async () => {
try {
const response = await getPrompt({ path: { name: selectedPrompt } });
if (response.data) {
setPromptData(response.data);
setContent(response.data.content);
}
} catch (error) {
console.error('Failed to fetch prompt:', error);
toast.error('Failed to load prompt');
}
};
fetchPrompt();
}
}, [selectedPrompt]);
useEffect(() => {
if (promptData) {
setHasChanges(content !== promptData.content);
}
}, [content, promptData]);
const handleResetAll = async () => {
if (
!window.confirm(
'Are you sure you want to reset all prompts to their defaults? This cannot be undone.'
)
) {
return;
}
try {
const customizedPrompts = prompts.filter((p) => p.is_customized);
for (const prompt of customizedPrompts) {
await resetPrompt({ path: { name: prompt.name } });
}
toast.success('All prompts reset to defaults');
fetchPrompts();
} catch (error) {
console.error('Failed to reset all prompts:', error);
toast.error('Failed to reset prompts');
}
};
const handleSave = async () => {
if (!selectedPrompt) return;
try {
await savePrompt({
path: { name: selectedPrompt },
body: { content },
});
toast.success('Prompt saved');
setPromptData((prev) => (prev ? { ...prev, content, is_customized: true } : null));
fetchPrompts();
} catch (error) {
console.error('Failed to save prompt:', error);
toast.error('Failed to save prompt');
}
};
const handleReset = async () => {
if (!selectedPrompt) return;
if (
!window.confirm(
'Are you sure you want to reset this prompt to its default? This cannot be undone.'
)
) {
return;
}
try {
await resetPrompt({ path: { name: selectedPrompt } });
if (promptData) {
setContent(promptData.default_content);
setPromptData({ ...promptData, content: promptData.default_content, is_customized: false });
}
fetchPrompts();
toast.success('Prompt reset to default');
} catch (error) {
console.error('Failed to reset prompt:', error);
toast.error('Failed to reset prompt');
}
};
const handleRestoreDefault = () => {
if (promptData) {
if (hasChanges) {
if (!window.confirm('Replace current content with default? Your changes will be lost.')) {
return;
}
}
setContent(promptData.default_content);
}
};
const handleBack = () => {
if (hasChanges) {
if (!window.confirm('You have unsaved changes. Are you sure you want to go back?')) {
return;
}
}
setSelectedPrompt(null);
setPromptData(null);
setContent('');
};
const hasCustomizedPrompts = prompts.some((p) => p.is_customized);
if (selectedPrompt) {
return (
<div className="space-y-4 pr-4 pb-8 mt-1">
<Card className="pb-2 rounded-lg">
<CardHeader className="pb-4">
<div className="flex items-center justify-between mb-4">
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to List
</Button>
<div className="flex items-center gap-2">
{promptData?.is_customized && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="flex items-center gap-2"
>
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
)}
<Button onClick={handleSave} disabled={!hasChanges} size="sm">
Save
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<CardTitle>Edit: {selectedPrompt}</CardTitle>
{promptData?.is_customized && (
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-600 dark:text-blue-400">
Customized
</span>
)}
</div>
</CardHeader>
<CardContent className="px-4 space-y-4 flex flex-col h-full">
<div className="text-sm text-text-muted bg-background-subtle p-3 rounded-lg">
<p>
<strong>Tip:</strong> Template variables like{' '}
<code className="bg-background-default px-1 rounded">{'{{ extensions }}'}</code> or{' '}
<code className="bg-background-default px-1 rounded">
{'{% for item in list %}'}
</code>{' '}
are replaced with actual values at runtime. Be careful not to remove required
variables.
</p>
</div>
<div className="space-y-2 flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Editing: {selectedPrompt}</label>
{promptData?.is_customized && content !== promptData.default_content && (
<Button
variant="ghost"
size="sm"
onClick={handleRestoreDefault}
className="text-xs"
>
Restore Default
</Button>
)}
</div>
<textarea
value={content}
className="w-full flex-1 min-h-[500px] border rounded-md p-3 text-sm font-mono resize-y bg-background-default text-textStandard border-borderStandard focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => setContent(e.target.value)}
placeholder="Enter prompt content..."
spellCheck={false}
/>
</div>
{hasChanges && (
<div className="text-sm text-yellow-600 dark:text-yellow-400">
You have unsaved changes
</div>
)}
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4 pr-4 pb-8 mt-1">
<Card className="pb-2 rounded-lg border-yellow-500/50 bg-yellow-500/10">
<CardHeader className="pb-2">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 flex-shrink-0 mt-1" />
<div className="flex-1">
<CardTitle className="text-yellow-600 dark:text-yellow-400">Prompt Editing</CardTitle>
<p className="text-sm text-text-muted mt-2">
Customize the prompts that define goose's behavior in different contexts. These
prompts use Jinja2 templating syntax. Be careful when modifying template variables,
as incorrect changes can break functionality. Please share any improvements with the
community.
</p>
</div>
{hasCustomizedPrompts && (
<Button
variant="outline"
size="sm"
onClick={handleResetAll}
className="flex items-center gap-2 border-yellow-500/50 hover:bg-yellow-500/20"
>
<RotateCcw className="h-4 w-4" />
Reset All
</Button>
)}
</div>
</CardHeader>
<CardContent className="px-4 pt-4">
<div className="space-y-2">
{prompts.map((prompt) => (
<div
key={prompt.name}
className="flex items-center justify-between p-3 rounded-lg border border-border-default hover:bg-background-subtle transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-text-default truncate">{prompt.name}</h4>
{prompt.is_customized && (
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-600 dark:text-blue-400">
Customized
</span>
)}
</div>
<p className="text-sm text-text-muted mt-0.5 truncate">{prompt.description}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedPrompt(prompt.name)}
className="ml-4"
>
Edit
</Button>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -6,9 +6,10 @@ import SessionSharingSection from './sessions/SessionSharingSection';
import ExternalBackendSection from './app/ExternalBackendSection';
import AppSettingsSection from './app/AppSettingsSection';
import ConfigSettings from './config/ConfigSettings';
import PromptsSettingsSection from './PromptsSettingsSection';
import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Bot, Share2, Monitor, MessageSquare } from 'lucide-react';
import { Bot, Share2, Monitor, MessageSquare, FileText } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import ChatSettingsSection from './chat/ChatSettingsSection';
import { CONFIGURATION_ENABLED } from '../../updates';
@@ -50,6 +51,7 @@ export default function SettingsView({
tools: 'chat',
app: 'app',
chat: 'chat',
prompts: 'prompts',
};
const targetTab = sectionToTab[viewOptions.section];
@@ -120,6 +122,14 @@ export default function SettingsView({
<Share2 className="h-4 w-4" />
Session
</TabsTrigger>
<TabsTrigger
value="prompts"
className="flex gap-2"
data-testid="settings-prompts-tab"
>
<FileText className="h-4 w-4" />
Prompts
</TabsTrigger>
<TabsTrigger value="app" className="flex gap-2" data-testid="settings-app-tab">
<Monitor className="h-4 w-4" />
App
@@ -152,6 +162,13 @@ export default function SettingsView({
</div>
</TabsContent>
<TabsContent
value="prompts"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<PromptsSettingsSection />
</TabsContent>
<TabsContent
value="app"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
@@ -214,20 +214,24 @@ export const SwitchModelModal = ({
results.forEach(({ provider: p, models, error }) => {
const modelList = error
? (p.metadata.known_models?.map(({ name }) => name) || [])
: (models || []);
? p.metadata.known_models?.map(({ name }) => name) || []
: models || [];
if (error) {
errors.push(error);
}
const options: { value: string; label: string; provider: string; providerType: ProviderType }[] =
modelList.map((m) => ({
value: m,
label: m,
provider: p.name,
providerType: p.provider_type,
}));
const options: {
value: string;
label: string;
provider: string;
providerType: ProviderType;
}[] = modelList.map((m) => ({
value: m,
label: m,
provider: p.name,
providerType: p.provider_type,
}));
if (p.metadata.allows_unlisted_models && p.provider_type !== 'Custom') {
options.push({
+2 -4
View File
@@ -215,10 +215,8 @@ export function useChatStream({
// The backend regenerates the name after each of the first 3 user messages
// to refine it as more context becomes available
if (!error && sessionId) {
const userMessageCount = messagesRef.current.filter(
(m) => m.role === 'user'
).length;
const userMessageCount = messagesRef.current.filter((m) => m.role === 'user').length;
// Only refresh for the first 3 user messages
if (userMessageCount <= 3) {
try {