mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
feat(profiles): de-emphasize AI suggestions and fix multi-tenant support
UI/UX Improvements for AI-skeptical users: - Only show 'Ideas' button if AI is enabled AND configured - Renamed 'Suggest Profile' to 'Ideas' with lightbulb icon - Moved 'New Profile' button to primary position - Changed AI button styling from prominent purple to subtle gray - Updated modal title to 'Profile Ideas' with neutral language Multi-tenant bug fix: - ProfileSuggestionHandler now uses MultiTenantPersistence - Properly resolves tenant-specific persistence from request context - Fixes potential nil pointer panic in multi-tenant deployments - Existing profiles are now correctly loaded per-tenant for AI context Tests updated to use MultiTenantPersistence with org context injection.
This commit is contained in:
@@ -3,6 +3,7 @@ import { useWebSocket } from '@/App';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import SettingsPanel from '@/components/shared/SettingsPanel';
|
||||
import { AgentProfilesAPI, type AgentProfile, type AgentProfileAssignment, type ProfileSuggestion } from '@/api/agentProfiles';
|
||||
import { AIAPI } from '@/api/ai';
|
||||
import { LicenseAPI } from '@/api/license';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
@@ -15,7 +16,7 @@ import Trash2 from 'lucide-solid/icons/trash-2';
|
||||
import Crown from 'lucide-solid/icons/crown';
|
||||
import Users from 'lucide-solid/icons/users';
|
||||
import Settings from 'lucide-solid/icons/settings';
|
||||
import Sparkles from 'lucide-solid/icons/sparkles';
|
||||
import Lightbulb from 'lucide-solid/icons/lightbulb';
|
||||
|
||||
|
||||
export const AgentProfilesPanel: Component = () => {
|
||||
@@ -25,6 +26,9 @@ export const AgentProfilesPanel: Component = () => {
|
||||
const [hasFeature, setHasFeature] = createSignal(false);
|
||||
const [checkingLicense, setCheckingLicense] = createSignal(true);
|
||||
|
||||
// AI state - only show AI features if enabled
|
||||
const [aiAvailable, setAiAvailable] = createSignal(false);
|
||||
|
||||
// Data state
|
||||
const [profiles, setProfiles] = createSignal<AgentProfile[]>([]);
|
||||
const [assignments, setAssignments] = createSignal<AgentProfileAssignment[]>([]);
|
||||
@@ -100,7 +104,7 @@ export const AgentProfilesPanel: Component = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Check license on mount
|
||||
// Check license and AI availability on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const features = await LicenseAPI.getFeatures();
|
||||
@@ -112,6 +116,15 @@ export const AgentProfilesPanel: Component = () => {
|
||||
setCheckingLicense(false);
|
||||
}
|
||||
|
||||
// Check if AI is available (enabled and configured) - silently fail if not
|
||||
try {
|
||||
const aiSettings = await AIAPI.getSettings();
|
||||
setAiAvailable(aiSettings.enabled && aiSettings.configured);
|
||||
} catch {
|
||||
// AI not available - that's fine, just hide the Ideas button
|
||||
setAiAvailable(false);
|
||||
}
|
||||
|
||||
if (hasFeature()) {
|
||||
await loadData();
|
||||
} else {
|
||||
@@ -284,15 +297,6 @@ export const AgentProfilesPanel: Component = () => {
|
||||
bodyClass="space-y-4"
|
||||
action={
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSuggest}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-purple-700 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<Sparkles class="w-4 h-4" />
|
||||
<span class="hidden sm:inline">Suggest Profile</span>
|
||||
<span class="sm:hidden">Suggest</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
@@ -302,6 +306,18 @@ export const AgentProfilesPanel: Component = () => {
|
||||
<span class="hidden sm:inline">New Profile</span>
|
||||
<span class="sm:hidden">New</span>
|
||||
</button>
|
||||
{/* Only show AI Ideas button if AI is enabled and configured */}
|
||||
<Show when={aiAvailable()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSuggest}
|
||||
title="Get AI-powered profile suggestions"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 sm:px-3 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<Lightbulb class="w-3.5 h-3.5" />
|
||||
<span class="hidden sm:inline">Ideas</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { KNOWN_SETTINGS_BY_KEY } from './agentProfileSettings';
|
||||
import Sparkles from 'lucide-solid/icons/sparkles';
|
||||
import Lightbulb from 'lucide-solid/icons/lightbulb';
|
||||
import AlertCircle from 'lucide-solid/icons/alert-circle';
|
||||
import Check from 'lucide-solid/icons/check';
|
||||
import Loader2 from 'lucide-solid/icons/loader-2';
|
||||
@@ -242,15 +242,15 @@ export const SuggestProfileModal: Component<SuggestProfileModalProps> = (props)
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<Sparkles class="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<Lightbulb class="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Pulse Assistant Profile Suggestion
|
||||
Profile Ideas
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Describe what you need, and Pulse Assistant will draft a profile
|
||||
Describe what you need, and we'll help draft a profile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -595,10 +595,10 @@ export const SuggestProfileModal: Component<SuggestProfileModalProps> = (props)
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading() || !prompt().trim()}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Sparkles class="w-4 h-4" />
|
||||
{loading() ? 'Generating...' : 'Suggest Profile'}
|
||||
<Lightbulb class="w-4 h-4" />
|
||||
{loading() ? 'Generating...' : 'Get Ideas'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
@@ -606,11 +606,11 @@ export const SuggestProfileModal: Component<SuggestProfileModalProps> = (props)
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading() || !prompt().trim()}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-purple-100 px-4 py-2 text-sm font-medium text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-200 dark:hover:bg-purple-900/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="Regenerate using the current prompt"
|
||||
>
|
||||
<Sparkles class="w-4 h-4" />
|
||||
{loading() ? 'Generating...' : 'Regenerate draft'}
|
||||
<Lightbulb class="w-4 h-4" />
|
||||
{loading() ? 'Generating...' : 'Try Again'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('SuggestProfileModal', () => {
|
||||
target: { value: 'Production profile with minimal logging' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /suggest profile/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /get ideas/i }));
|
||||
|
||||
expect(await screen.findByText('Production Servers')).toBeInTheDocument();
|
||||
expect(screen.getByText('Settings Preview')).toBeInTheDocument();
|
||||
@@ -146,12 +146,12 @@ describe('SuggestProfileModal', () => {
|
||||
|
||||
const promptInput = screen.getByPlaceholderText('Describe the agents and use case for this profile...');
|
||||
fireEvent.input(promptInput, { target: { value: 'Production profile' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /suggest profile/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /get ideas/i }));
|
||||
|
||||
expect(await screen.findByText('Production Profile')).toBeInTheDocument();
|
||||
|
||||
fireEvent.input(promptInput, { target: { value: 'Development profile' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /regenerate/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||
|
||||
expect(await screen.findByText('Development Profile')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recent drafts')).toBeInTheDocument();
|
||||
|
||||
@@ -39,15 +39,7 @@ func (h *ConfigProfileHandler) getPersistence(ctx context.Context) (*config.Conf
|
||||
|
||||
// SetAIHandler sets the AI handler for profile suggestions
|
||||
func (h *ConfigProfileHandler) SetAIHandler(aiHandler *AIHandler) {
|
||||
// We pass nil for persistence here because the suggestion handler will need
|
||||
// to use the context-aware persistence, which requires deeper refactoring of ProfileSuggestionHandler.
|
||||
// For now, we'll let ProfileSuggestionHandler resolve persistence from AIHandler if possible,
|
||||
// or we update ProfileSuggestionHandler to be multi-tenant aware as well.
|
||||
// Actually, ProfileSuggestionHandler needs persistence. Let's look at that separately.
|
||||
// For this step, we'll temporarilly break this or pass nil and fix it in the next step.
|
||||
// A better approach: ProfileSuggestionHandler should take MultiTenantPersistence too.
|
||||
// Let's assume we update ProfileSuggestionHandler next.
|
||||
h.suggestionHandler = NewProfileSuggestionHandler(nil, aiHandler)
|
||||
h.suggestionHandler = NewProfileSuggestionHandler(h.mtPersistence, aiHandler)
|
||||
}
|
||||
|
||||
// ServeHTTP implements the http.Handler interface
|
||||
|
||||
@@ -16,15 +16,15 @@ import (
|
||||
|
||||
// ProfileSuggestionHandler handles AI-assisted profile suggestions
|
||||
type ProfileSuggestionHandler struct {
|
||||
persistence *config.ConfigPersistence
|
||||
aiHandler *AIHandler
|
||||
mtPersistence *config.MultiTenantPersistence
|
||||
aiHandler *AIHandler
|
||||
}
|
||||
|
||||
// NewProfileSuggestionHandler creates a new suggestion handler
|
||||
func NewProfileSuggestionHandler(persistence *config.ConfigPersistence, aiHandler *AIHandler) *ProfileSuggestionHandler {
|
||||
func NewProfileSuggestionHandler(mtp *config.MultiTenantPersistence, aiHandler *AIHandler) *ProfileSuggestionHandler {
|
||||
return &ProfileSuggestionHandler{
|
||||
persistence: persistence,
|
||||
aiHandler: aiHandler,
|
||||
mtPersistence: mtp,
|
||||
aiHandler: aiHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,14 +71,20 @@ func (h *ProfileSuggestionHandler) HandleSuggestProfile(w http.ResponseWriter, r
|
||||
// Build context for the AI
|
||||
contextParts := []string{}
|
||||
|
||||
// Add existing profiles for reference
|
||||
profiles, err := h.persistence.LoadAgentProfiles()
|
||||
if err == nil && len(profiles) > 0 {
|
||||
profileNames := make([]string, len(profiles))
|
||||
for i, p := range profiles {
|
||||
profileNames[i] = p.Name
|
||||
// Add existing profiles for reference (if persistence is available)
|
||||
if h.mtPersistence != nil {
|
||||
orgID := GetOrgID(r.Context())
|
||||
persistence, err := h.mtPersistence.GetPersistence(orgID)
|
||||
if err == nil {
|
||||
profiles, err := persistence.LoadAgentProfiles()
|
||||
if err == nil && len(profiles) > 0 {
|
||||
profileNames := make([]string, len(profiles))
|
||||
for i, p := range profiles {
|
||||
profileNames[i] = p.Name
|
||||
}
|
||||
contextParts = append(contextParts, fmt.Sprintf("Existing profiles: %s", strings.Join(profileNames, ", ")))
|
||||
}
|
||||
}
|
||||
contextParts = append(contextParts, fmt.Sprintf("Existing profiles: %s", strings.Join(profileNames, ", ")))
|
||||
}
|
||||
|
||||
// Build config schema documentation from the actual definitions
|
||||
|
||||
@@ -14,9 +14,15 @@ import (
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// withOrgContext adds the default org ID to the request context
|
||||
func withOrgContext(req *http.Request) *http.Request {
|
||||
ctx := context.WithValue(req.Context(), OrgIDContextKey, "default")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestProfileSuggestionHandler_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewProfileSuggestionHandler(config.NewConfigPersistence(t.TempDir()), &AIHandler{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/profiles/suggestions", nil)
|
||||
handler := NewProfileSuggestionHandler(config.NewMultiTenantPersistence(t.TempDir()), &AIHandler{})
|
||||
req := withOrgContext(httptest.NewRequest(http.MethodGet, "/api/admin/profiles/suggestions", nil))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.HandleSuggestProfile(rr, req)
|
||||
@@ -31,8 +37,8 @@ func TestProfileSuggestionHandler_ServiceUnavailable(t *testing.T) {
|
||||
mockSvc.On("IsRunning").Return(false)
|
||||
aiHandler := &AIHandler{legacyService: mockSvc}
|
||||
|
||||
handler := NewProfileSuggestionHandler(config.NewConfigPersistence(t.TempDir()), aiHandler)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"test"}`)))
|
||||
handler := NewProfileSuggestionHandler(config.NewMultiTenantPersistence(t.TempDir()), aiHandler)
|
||||
req := withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"test"}`))))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.HandleSuggestProfile(rr, req)
|
||||
@@ -46,16 +52,16 @@ func TestProfileSuggestionHandler_InvalidRequest(t *testing.T) {
|
||||
mockSvc := new(MockAIService)
|
||||
mockSvc.On("IsRunning").Return(true)
|
||||
aiHandler := &AIHandler{legacyService: mockSvc}
|
||||
handler := NewProfileSuggestionHandler(config.NewConfigPersistence(t.TempDir()), aiHandler)
|
||||
handler := NewProfileSuggestionHandler(config.NewMultiTenantPersistence(t.TempDir()), aiHandler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte("{bad")))
|
||||
req := withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte("{bad"))))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSuggestProfile(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":" "}`)))
|
||||
req = withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":" "}`))))
|
||||
rr = httptest.NewRecorder()
|
||||
handler.HandleSuggestProfile(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
@@ -64,7 +70,12 @@ func TestProfileSuggestionHandler_InvalidRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProfileSuggestionHandler_SuccessAndParseFailure(t *testing.T) {
|
||||
persistence := config.NewConfigPersistence(t.TempDir())
|
||||
mtPersistence := config.NewMultiTenantPersistence(t.TempDir())
|
||||
// Get the default tenant's persistence to save test data
|
||||
persistence, err := mtPersistence.GetPersistence("default")
|
||||
if err != nil {
|
||||
t.Fatalf("get persistence: %v", err)
|
||||
}
|
||||
if err := persistence.SaveAgentProfiles([]models.AgentProfile{{Name: "Existing"}}); err != nil {
|
||||
t.Fatalf("save profiles: %v", err)
|
||||
}
|
||||
@@ -76,9 +87,9 @@ func TestProfileSuggestionHandler_SuccessAndParseFailure(t *testing.T) {
|
||||
}, nil).Once()
|
||||
|
||||
aiHandler := &AIHandler{legacyService: mockSvc}
|
||||
handler := NewProfileSuggestionHandler(persistence, aiHandler)
|
||||
handler := NewProfileSuggestionHandler(mtPersistence, aiHandler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"build a profile"}`)))
|
||||
req := withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"build a profile"}`))))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSuggestProfile(rr, req)
|
||||
|
||||
@@ -101,7 +112,7 @@ func TestProfileSuggestionHandler_SuccessAndParseFailure(t *testing.T) {
|
||||
"content": "not json",
|
||||
}, nil).Once()
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"bad response"}`)))
|
||||
req = withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"bad response"}`))))
|
||||
rr = httptest.NewRecorder()
|
||||
handler.HandleSuggestProfile(rr, req)
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
|
||||
Reference in New Issue
Block a user