feat(#82): implement Personality Module
- Add Personality model to Prisma schema with FormalityLevel enum - Create migration and seed with 6 default personalities - Implement CRUD API with TDD approach (97.67% coverage) * PersonalitiesService: findAll, findOne, findDefault, create, update, remove * PersonalitiesController: REST endpoints with auth guards * Comprehensive test coverage (21 passing tests) - Add Personality types to shared package - Create frontend components: * PersonalitySelector: dropdown for choosing personality * PersonalityPreview: preview personality style and system prompt * PersonalityForm: create/edit personalities with validation * Settings page: manage personalities with CRUD operations - Integrate with Ollama API: * Support personalityId in chat endpoint * Auto-inject system prompt from personality * Fall back to default personality if not specified - API client for frontend personality management All tests passing with 97.67% backend coverage (exceeds 85% requirement)
This commit is contained in:
195
apps/web/src/components/personalities/PersonalityForm.tsx
Normal file
195
apps/web/src/components/personalities/PersonalityForm.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Personality, FormalityLevel } from "@mosaic/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export interface PersonalityFormData {
|
||||
name: string;
|
||||
description?: string;
|
||||
tone: string;
|
||||
formalityLevel: FormalityLevel;
|
||||
systemPromptTemplate: string;
|
||||
isDefault?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface PersonalityFormProps {
|
||||
personality?: Personality;
|
||||
onSubmit: (data: PersonalityFormData) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const FORMALITY_OPTIONS = [
|
||||
{ value: "VERY_CASUAL", label: "Very Casual" },
|
||||
{ value: "CASUAL", label: "Casual" },
|
||||
{ value: "NEUTRAL", label: "Neutral" },
|
||||
{ value: "FORMAL", label: "Formal" },
|
||||
{ value: "VERY_FORMAL", label: "Very Formal" },
|
||||
];
|
||||
|
||||
export function PersonalityForm({ personality, onSubmit, onCancel }: PersonalityFormProps): JSX.Element {
|
||||
const [formData, setFormData] = useState<PersonalityFormData>({
|
||||
name: personality?.name || "",
|
||||
description: personality?.description || "",
|
||||
tone: personality?.tone || "",
|
||||
formalityLevel: personality?.formalityLevel || "NEUTRAL",
|
||||
systemPromptTemplate: personality?.systemPromptTemplate || "",
|
||||
isDefault: personality?.isDefault || false,
|
||||
isActive: personality?.isActive ?? true,
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{personality ? "Edit Personality" : "Create New Personality"}</CardTitle>
|
||||
<CardDescription>
|
||||
Customize how the AI assistant communicates and responds
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., Professional, Casual, Friendly"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Brief description of this personality style"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tone */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tone">Tone *</Label>
|
||||
<Input
|
||||
id="tone"
|
||||
value={formData.tone}
|
||||
onChange={(e) => setFormData({ ...formData, tone: e.target.value })}
|
||||
placeholder="e.g., professional, friendly, enthusiastic"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Formality Level */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="formality">Formality Level *</Label>
|
||||
<Select
|
||||
value={formData.formalityLevel}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, formalityLevel: value as FormalityLevel })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="formality">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FORMALITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* System Prompt Template */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="systemPrompt">System Prompt Template *</Label>
|
||||
<Textarea
|
||||
id="systemPrompt"
|
||||
value={formData.systemPromptTemplate}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, systemPromptTemplate: e.target.value })
|
||||
}
|
||||
placeholder="You are a helpful AI assistant..."
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This template guides the AI's communication style and behavior
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Switches */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="isDefault">Set as Default</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use this personality by default for new conversations
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="isDefault"
|
||||
checked={formData.isDefault}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isDefault: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="isActive">Active</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Make this personality available for selection
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : personality ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
121
apps/web/src/components/personalities/PersonalityPreview.tsx
Normal file
121
apps/web/src/components/personalities/PersonalityPreview.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Personality } from "@mosaic/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
interface PersonalityPreviewProps {
|
||||
personality: Personality;
|
||||
}
|
||||
|
||||
const SAMPLE_PROMPTS = [
|
||||
"Explain quantum computing in simple terms",
|
||||
"What's the best way to organize my tasks?",
|
||||
"Help me brainstorm ideas for a new project",
|
||||
];
|
||||
|
||||
const FORMALITY_LABELS: Record<string, string> = {
|
||||
VERY_CASUAL: "Very Casual",
|
||||
CASUAL: "Casual",
|
||||
NEUTRAL: "Neutral",
|
||||
FORMAL: "Formal",
|
||||
VERY_FORMAL: "Very Formal",
|
||||
};
|
||||
|
||||
export function PersonalityPreview({ personality }: PersonalityPreviewProps): JSX.Element {
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<string>(SAMPLE_PROMPTS[0]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
{personality.name}
|
||||
</CardTitle>
|
||||
<CardDescription>{personality.description}</CardDescription>
|
||||
</div>
|
||||
{personality.isDefault && (
|
||||
<Badge variant="secondary">Default</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Personality Attributes */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tone:</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{personality.tone}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Formality:</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{FORMALITY_LABELS[personality.formalityLevel]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Interaction */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Preview with Sample Prompt:</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SAMPLE_PROMPTS.map((prompt) => (
|
||||
<Button
|
||||
key={prompt}
|
||||
variant={selectedPrompt === prompt ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedPrompt(prompt)}
|
||||
>
|
||||
{prompt.substring(0, 30)}...
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Prompt Template */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">System Prompt Template:</label>
|
||||
<Textarea
|
||||
value={personality.systemPromptTemplate}
|
||||
readOnly
|
||||
className="min-h-[100px] bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mock Response Preview */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Sample Response Style:</label>
|
||||
<div className="rounded-md border bg-muted/50 p-4 text-sm">
|
||||
<p className="italic text-muted-foreground">
|
||||
"{selectedPrompt}"
|
||||
</p>
|
||||
<div className="mt-2 text-foreground">
|
||||
{personality.formalityLevel === "VERY_CASUAL" && (
|
||||
<p>Hey! So quantum computing is like... imagine if your computer could be in multiple places at once. Pretty wild, right? 🤯</p>
|
||||
)}
|
||||
{personality.formalityLevel === "CASUAL" && (
|
||||
<p>Sure! Think of quantum computing like a super-powered calculator that can try lots of solutions at the same time.</p>
|
||||
)}
|
||||
{personality.formalityLevel === "NEUTRAL" && (
|
||||
<p>Quantum computing uses quantum mechanics principles to process information differently from classical computers, enabling parallel computation.</p>
|
||||
)}
|
||||
{personality.formalityLevel === "FORMAL" && (
|
||||
<p>Quantum computing represents a paradigm shift in computational methodology, leveraging quantum mechanical phenomena to perform calculations.</p>
|
||||
)}
|
||||
{personality.formalityLevel === "VERY_FORMAL" && (
|
||||
<p>Quantum computing constitutes a fundamental departure from classical computational architectures, employing quantum superposition and entanglement principles.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Personality } from "@mosaic/shared";
|
||||
import { fetchPersonalities } from "@/lib/api/personalities";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface PersonalitySelectorProps {
|
||||
value?: string;
|
||||
onChange?: (personalityId: string) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PersonalitySelector({
|
||||
value,
|
||||
onChange,
|
||||
label = "Select Personality",
|
||||
className,
|
||||
}: PersonalitySelectorProps): JSX.Element {
|
||||
const [personalities, setPersonalities] = useState<Personality[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadPersonalities();
|
||||
}, []);
|
||||
|
||||
async function loadPersonalities(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchPersonalities();
|
||||
setPersonalities(response.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load personalities:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<Label htmlFor="personality-select" className="mb-2">
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<Select value={value} onValueChange={onChange} disabled={isLoading}>
|
||||
<SelectTrigger id="personality-select">
|
||||
<SelectValue placeholder={isLoading ? "Loading..." : "Choose a personality"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{personalities.map((personality) => (
|
||||
<SelectItem key={personality.id} value={personality.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{personality.name}</span>
|
||||
{personality.isDefault && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user