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:
Jason Woltje
2026-01-29 17:57:54 -06:00
parent 95833fb4ea
commit 5dd46c85af
43 changed files with 4782 additions and 2 deletions

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DomainFilter } from "./DomainFilter";
import type { Domain } from "@mosaic/shared";
describe("DomainFilter", () => {
const mockDomains: Domain[] = [
{
id: "domain-1",
workspaceId: "workspace-1",
name: "Work",
slug: "work",
description: "Work-related tasks",
color: "#3B82F6",
icon: "💼",
sortOrder: 0,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "domain-2",
workspaceId: "workspace-1",
name: "Personal",
slug: "personal",
description: null,
color: "#10B981",
icon: "🏠",
sortOrder: 1,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
it("should render All button", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
expect(screen.getByRole("button", { name: /all/i })).toBeInTheDocument();
});
it("should render domain filter buttons", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
expect(screen.getByRole("button", { name: /filter by work/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /filter by personal/i })).toBeInTheDocument();
});
it("should highlight All when no domain selected", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
const allButton = screen.getByRole("button", { name: /all/i });
expect(allButton.getAttribute("aria-pressed")).toBe("true");
});
it("should highlight selected domain", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain="domain-1"
onFilterChange={onFilterChange}
/>
);
const workButton = screen.getByRole("button", { name: /filter by work/i });
expect(workButton.getAttribute("aria-pressed")).toBe("true");
});
it("should call onFilterChange when All clicked", async () => {
const user = userEvent.setup();
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain="domain-1"
onFilterChange={onFilterChange}
/>
);
const allButton = screen.getByRole("button", { name: /all/i });
await user.click(allButton);
expect(onFilterChange).toHaveBeenCalledWith(null);
});
it("should call onFilterChange when domain clicked", async () => {
const user = userEvent.setup();
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
const workButton = screen.getByRole("button", { name: /filter by work/i });
await user.click(workButton);
expect(onFilterChange).toHaveBeenCalledWith("domain-1");
});
it("should display domain icons", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
expect(screen.getByText("💼")).toBeInTheDocument();
expect(screen.getByText("🏠")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,52 @@
"use client";
import type { Domain } from "@mosaic/shared";
interface DomainFilterProps {
domains: Domain[];
selectedDomain: string | null;
onFilterChange: (domainId: string | null) => void;
}
export function DomainFilter({
domains,
selectedDomain,
onFilterChange,
}: DomainFilterProps): JSX.Element {
return (
<div className="flex gap-2 flex-wrap">
<button
onClick={() => onFilterChange(null)}
className={`px-3 py-1 rounded-full text-sm ${
selectedDomain === null
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
aria-label="Show all domains"
aria-pressed={selectedDomain === null}
>
All
</button>
{domains.map((domain) => (
<button
key={domain.id}
onClick={() => onFilterChange(domain.id)}
className={`px-3 py-1 rounded-full text-sm flex items-center gap-1 ${
selectedDomain === domain.id
? "text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
style={{
backgroundColor:
selectedDomain === domain.id ? domain.color || "#374151" : undefined,
}}
aria-label={`Filter by ${domain.name}`}
aria-pressed={selectedDomain === domain.id}
>
{domain.icon && <span>{domain.icon}</span>}
<span>{domain.name}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import type { Domain } from "@mosaic/shared";
interface DomainItemProps {
domain: Domain;
onEdit?: (domain: Domain) => void;
onDelete?: (domain: Domain) => void;
}
export function DomainItem({
domain,
onEdit,
onDelete,
}: DomainItemProps): JSX.Element {
return (
<div className="border rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{domain.icon && <span className="text-2xl">{domain.icon}</span>}
{domain.color && (
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: domain.color }}
/>
)}
<h3 className="font-semibold text-lg">{domain.name}</h3>
</div>
{domain.description && (
<p className="text-sm text-gray-600">{domain.description}</p>
)}
<div className="mt-2">
<span className="text-xs text-gray-500 font-mono">
{domain.slug}
</span>
</div>
</div>
<div className="flex gap-2 ml-4">
{onEdit && (
<button
onClick={() => onEdit(domain)}
className="text-sm px-3 py-1 border rounded hover:bg-gray-50"
aria-label={`Edit ${domain.name}`}
>
Edit
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(domain)}
className="text-sm px-3 py-1 border border-red-300 text-red-600 rounded hover:bg-red-50"
aria-label={`Delete ${domain.name}`}
>
Delete
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { DomainList } from "./DomainList";
import type { Domain } from "@mosaic/shared";
describe("DomainList", () => {
const mockDomains: Domain[] = [
{
id: "domain-1",
workspaceId: "workspace-1",
name: "Work",
slug: "work",
description: "Work-related tasks",
color: "#3B82F6",
icon: "💼",
sortOrder: 0,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "domain-2",
workspaceId: "workspace-1",
name: "Personal",
slug: "personal",
description: "Personal tasks and projects",
color: "#10B981",
icon: "🏠",
sortOrder: 1,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
it("should render empty state when no domains", () => {
render(<DomainList domains={[]} isLoading={false} />);
expect(screen.getByText(/no domains created yet/i)).toBeInTheDocument();
});
it("should render loading state", () => {
render(<DomainList domains={[]} isLoading={true} />);
expect(screen.getByText(/loading domains/i)).toBeInTheDocument();
});
it("should render domains list", () => {
render(<DomainList domains={mockDomains} isLoading={false} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
});
it("should call onEdit when edit button clicked", () => {
const onEdit = vi.fn();
render(
<DomainList
domains={mockDomains}
isLoading={false}
onEdit={onEdit}
/>
);
const editButtons = screen.getAllByRole("button", { name: /edit/i });
editButtons[0].click();
expect(onEdit).toHaveBeenCalledWith(mockDomains[0]);
});
it("should call onDelete when delete button clicked", () => {
const onDelete = vi.fn();
render(
<DomainList
domains={mockDomains}
isLoading={false}
onDelete={onDelete}
/>
);
const deleteButtons = screen.getAllByRole("button", { name: /delete/i });
deleteButtons[0].click();
expect(onDelete).toHaveBeenCalledWith(mockDomains[0]);
});
it("should handle undefined domains gracefully", () => {
// @ts-expect-error Testing error state
render(<DomainList domains={undefined} isLoading={false} />);
expect(screen.getByText(/no domains created yet/i)).toBeInTheDocument();
});
it("should handle null domains gracefully", () => {
// @ts-expect-error Testing error state
render(<DomainList domains={null} isLoading={false} />);
expect(screen.getByText(/no domains created yet/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,51 @@
"use client";
import type { Domain } from "@mosaic/shared";
import { DomainItem } from "./DomainItem";
interface DomainListProps {
domains: Domain[];
isLoading: boolean;
onEdit?: (domain: Domain) => void;
onDelete?: (domain: Domain) => void;
}
export function DomainList({
domains,
isLoading,
onEdit,
onDelete,
}: DomainListProps): JSX.Element {
if (isLoading) {
return (
<div className="flex justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading domains...</span>
</div>
);
}
if (!domains || domains.length === 0) {
return (
<div className="text-center p-8 text-gray-500">
<p className="text-lg">No domains created yet</p>
<p className="text-sm mt-2">
Create domains to organize your tasks and projects
</p>
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{domains.map((domain) => (
<DomainItem
key={domain.id}
domain={domain}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DomainSelector } from "./DomainSelector";
import type { Domain } from "@mosaic/shared";
describe("DomainSelector", () => {
const mockDomains: Domain[] = [
{
id: "domain-1",
workspaceId: "workspace-1",
name: "Work",
slug: "work",
description: "Work-related tasks",
color: "#3B82F6",
icon: "💼",
sortOrder: 0,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "domain-2",
workspaceId: "workspace-1",
name: "Personal",
slug: "personal",
description: null,
color: "#10B981",
icon: null,
sortOrder: 1,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
it("should render with default placeholder", () => {
const onChange = vi.fn();
render(
<DomainSelector domains={mockDomains} value={null} onChange={onChange} />
);
expect(screen.getByText("Select a domain")).toBeInTheDocument();
});
it("should render with custom placeholder", () => {
const onChange = vi.fn();
render(
<DomainSelector
domains={mockDomains}
value={null}
onChange={onChange}
placeholder="Choose domain"
/>
);
expect(screen.getByText("Choose domain")).toBeInTheDocument();
});
it("should render all domains as options", () => {
const onChange = vi.fn();
render(
<DomainSelector domains={mockDomains} value={null} onChange={onChange} />
);
expect(screen.getByText("💼 Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
});
it("should call onChange when selection changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<DomainSelector domains={mockDomains} value={null} onChange={onChange} />
);
const select = screen.getByRole("combobox");
await user.selectOptions(select, "domain-1");
expect(onChange).toHaveBeenCalledWith("domain-1");
});
it("should call onChange with null when cleared", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<DomainSelector
domains={mockDomains}
value="domain-1"
onChange={onChange}
/>
);
const select = screen.getByRole("combobox");
await user.selectOptions(select, "");
expect(onChange).toHaveBeenCalledWith(null);
});
it("should show selected value", () => {
const onChange = vi.fn();
render(
<DomainSelector
domains={mockDomains}
value="domain-1"
onChange={onChange}
/>
);
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("domain-1");
});
it("should apply custom className", () => {
const onChange = vi.fn();
render(
<DomainSelector
domains={mockDomains}
value={null}
onChange={onChange}
className="custom-class"
/>
);
const select = screen.getByRole("combobox");
expect(select.className).toContain("custom-class");
});
});

View File

@@ -0,0 +1,38 @@
"use client";
import type { Domain } from "@mosaic/shared";
interface DomainSelectorProps {
domains: Domain[];
value: string | null;
onChange: (domainId: string | null) => void;
placeholder?: string;
className?: string;
}
export function DomainSelector({
domains,
value,
onChange,
placeholder = "Select a domain",
className = "",
}: DomainSelectorProps): JSX.Element {
return (
<select
value={value ?? ""}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange(e.target.value || null)
}
className={`border rounded px-3 py-2 ${className}`}
aria-label="Domain selector"
>
<option value="">{placeholder}</option>
{domains.map((domain) => (
<option key={domain.id} value={domain.id}>
{domain.icon ? `${domain.icon} ` : ""}
{domain.name}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FilterBar } from "./FilterBar";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
describe("FilterBar", () => {
const mockOnFilterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should render search input", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
it("should render status filter", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByRole("button", { name: /status/i })).toBeInTheDocument();
});
it("should render priority filter", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByRole("button", { name: /priority/i })).toBeInTheDocument();
});
it("should render date range picker", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByPlaceholderText(/from date/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/to date/i)).toBeInTheDocument();
});
it("should render clear filters button when filters applied", () => {
render(
<FilterBar
onFilterChange={mockOnFilterChange}
initialFilters={{ search: "test" }}
/>
);
expect(screen.getByRole("button", { name: /clear filters/i })).toBeInTheDocument();
});
it("should not render clear filters button when no filters applied", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.queryByRole("button", { name: /clear filters/i })).not.toBeInTheDocument();
});
it("should debounce search input", async () => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} debounceMs={300} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, "test query");
// Should not call immediately
expect(mockOnFilterChange).not.toHaveBeenCalled();
// Should call after debounce delay
await waitFor(
() => {
expect(mockOnFilterChange).toHaveBeenCalledWith(
expect.objectContaining({ search: "test query" })
);
},
{ timeout: 500 }
);
});
it("should clear all filters when clear button clicked", async () => {
const user = userEvent.setup();
render(
<FilterBar
onFilterChange={mockOnFilterChange}
initialFilters={{
search: "test",
status: [TaskStatus.IN_PROGRESS],
priority: [TaskPriority.HIGH],
}}
/>
);
const clearButton = screen.getByRole("button", { name: /clear filters/i });
await user.click(clearButton);
expect(mockOnFilterChange).toHaveBeenCalledWith({});
});
it("should handle status selection", async () => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} />);
const statusButton = screen.getByRole("button", { name: /status/i });
await user.click(statusButton);
// Note: Actual multi-select implementation would need to open a dropdown
// This is a simplified test
});
it("should handle priority selection", async () => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} />);
const priorityButton = screen.getByRole("button", { name: /priority/i });
await user.click(priorityButton);
// Note: Actual implementation would need to open a dropdown
});
it("should handle date range selection", async () => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} />);
const fromDate = screen.getByPlaceholderText(/from date/i);
const toDate = screen.getByPlaceholderText(/to date/i);
await user.type(fromDate, "2024-01-01");
await user.type(toDate, "2024-12-31");
await waitFor(() => {
expect(mockOnFilterChange).toHaveBeenCalled();
});
});
it("should display active filter count", () => {
render(
<FilterBar
onFilterChange={mockOnFilterChange}
initialFilters={{
status: [TaskStatus.IN_PROGRESS, TaskStatus.NOT_STARTED],
priority: [TaskPriority.HIGH],
}}
/>
);
// Should show 3 active filters (2 statuses + 1 priority)
expect(screen.getByText(/3/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,207 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
export interface FilterValues {
search?: string;
status?: TaskStatus[];
priority?: TaskPriority[];
dateFrom?: string;
dateTo?: string;
sortBy?: string;
sortOrder?: "asc" | "desc";
}
interface FilterBarProps {
onFilterChange: (filters: FilterValues) => void;
initialFilters?: FilterValues;
debounceMs?: number;
}
export function FilterBar({
onFilterChange,
initialFilters = {},
debounceMs = 300,
}: FilterBarProps) {
const [filters, setFilters] = useState<FilterValues>(initialFilters);
const [searchValue, setSearchValue] = useState(initialFilters.search || "");
const [showStatusDropdown, setShowStatusDropdown] = useState(false);
const [showPriorityDropdown, setShowPriorityDropdown] = useState(false);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (searchValue !== filters.search) {
const newFilters = { ...filters, search: searchValue || undefined };
setFilters(newFilters);
onFilterChange(newFilters);
}
}, debounceMs);
return () => clearTimeout(timer);
}, [searchValue, debounceMs]);
const handleFilterChange = useCallback(
(key: keyof FilterValues, value: any) => {
const newFilters = { ...filters, [key]: value };
if (!value || (Array.isArray(value) && value.length === 0)) {
delete newFilters[key];
}
setFilters(newFilters);
onFilterChange(newFilters);
},
[filters, onFilterChange]
);
const handleStatusToggle = (status: TaskStatus) => {
const currentStatuses = filters.status || [];
const newStatuses = currentStatuses.includes(status)
? currentStatuses.filter((s) => s !== status)
: [...currentStatuses, status];
handleFilterChange("status", newStatuses.length > 0 ? newStatuses : undefined);
};
const handlePriorityToggle = (priority: TaskPriority) => {
const currentPriorities = filters.priority || [];
const newPriorities = currentPriorities.includes(priority)
? currentPriorities.filter((p) => p !== priority)
: [...currentPriorities, priority];
handleFilterChange("priority", newPriorities.length > 0 ? newPriorities : undefined);
};
const clearAllFilters = () => {
setFilters({});
setSearchValue("");
onFilterChange({});
};
const activeFilterCount =
(filters.status?.length || 0) +
(filters.priority?.length || 0) +
(filters.search ? 1 : 0) +
(filters.dateFrom ? 1 : 0) +
(filters.dateTo ? 1 : 0);
const hasActiveFilters = activeFilterCount > 0;
return (
<div className="flex flex-wrap items-center gap-2 p-4 bg-gray-50 rounded-lg">
{/* Search Input */}
<div className="relative flex-1 min-w-[200px]">
<input
type="text"
placeholder="Search tasks..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Status Filter */}
<div className="relative">
<button
onClick={() => setShowStatusDropdown(!showStatusDropdown)}
className="px-3 py-2 border border-gray-300 rounded-md hover:bg-gray-100 flex items-center gap-2"
aria-label="Status filter"
>
Status
{filters.status && filters.status.length > 0 && (
<span className="bg-blue-500 text-white text-xs px-2 py-0.5 rounded-full">
{filters.status.length}
</span>
)}
</button>
{showStatusDropdown && (
<div className="absolute top-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg z-10 min-w-[150px]">
{Object.values(TaskStatus).map((status) => (
<label
key={status}
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
>
<input
type="checkbox"
checked={filters.status?.includes(status) || false}
onChange={() => handleStatusToggle(status)}
className="mr-2"
/>
{status.replace(/_/g, " ")}
</label>
))}
</div>
)}
</div>
{/* Priority Filter */}
<div className="relative">
<button
onClick={() => setShowPriorityDropdown(!showPriorityDropdown)}
className="px-3 py-2 border border-gray-300 rounded-md hover:bg-gray-100 flex items-center gap-2"
aria-label="Priority filter"
>
Priority
{filters.priority && filters.priority.length > 0 && (
<span className="bg-blue-500 text-white text-xs px-2 py-0.5 rounded-full">
{filters.priority.length}
</span>
)}
</button>
{showPriorityDropdown && (
<div className="absolute top-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg z-10 min-w-[150px]">
{Object.values(TaskPriority).map((priority) => (
<label
key={priority}
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
>
<input
type="checkbox"
checked={filters.priority?.includes(priority) || false}
onChange={() => handlePriorityToggle(priority)}
className="mr-2"
/>
{priority}
</label>
))}
</div>
)}
</div>
{/* Date Range */}
<div className="flex items-center gap-2">
<input
type="date"
placeholder="From date"
value={filters.dateFrom || ""}
onChange={(e) => handleFilterChange("dateFrom", e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-gray-500">to</span>
<input
type="date"
placeholder="To date"
value={filters.dateTo || ""}
onChange={(e) => handleFilterChange("dateTo", e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Clear Filters */}
{hasActiveFilters && (
<button
onClick={clearAllFilters}
className="ml-auto px-3 py-2 text-gray-700 hover:bg-gray-100 rounded-md"
aria-label="Clear filters"
>
Clear filters
</button>
)}
{/* Active Filter Count Badge */}
{activeFilterCount > 0 && (
<span className="bg-blue-500 text-white text-sm px-3 py-1 rounded-full">
{activeFilterCount}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./FilterBar";

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}