User configurable templates (#6420)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user