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,156 @@
import {
Injectable,
NotFoundException,
ConflictException,
Logger,
} from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { Personality } from "./entities/personality.entity";
@Injectable()
export class PersonalitiesService {
private readonly logger = new Logger(PersonalitiesService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Find all personalities for a workspace
*/
async findAll(workspaceId: string, isActive: boolean = true): Promise<Personality[]> {
return this.prisma.personality.findMany({
where: { workspaceId, isActive },
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
}
/**
* Find a specific personality by ID
*/
async findOne(workspaceId: string, id: string): Promise<Personality> {
const personality = await this.prisma.personality.findUnique({
where: { id, workspaceId },
});
if (!personality) {
throw new NotFoundException(`Personality with ID ${id} not found`);
}
return personality;
}
/**
* Find the default personality for a workspace
*/
async findDefault(workspaceId: string): Promise<Personality> {
const personality = await this.prisma.personality.findFirst({
where: { workspaceId, isDefault: true, isActive: true },
});
if (!personality) {
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
}
return personality;
}
/**
* Create a new personality
*/
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
// Check for duplicate name
const existing = await this.prisma.personality.findFirst({
where: { workspaceId, name: dto.name },
});
if (existing) {
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
}
// If creating a default personality, unset other defaults
if (dto.isDefault) {
await this.unsetOtherDefaults(workspaceId);
}
const personality = await this.prisma.personality.create({
data: {
workspaceId,
...dto,
},
});
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
return personality;
}
/**
* Update an existing personality
*/
async update(
workspaceId: string,
id: string,
dto: UpdatePersonalityDto,
): Promise<Personality> {
// Check existence
await this.findOne(workspaceId, id);
// Check for duplicate name if updating name
if (dto.name) {
const existing = await this.prisma.personality.findFirst({
where: { workspaceId, name: dto.name, id: { not: id } },
});
if (existing) {
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
}
}
// If setting as default, unset other defaults
if (dto.isDefault === true) {
await this.unsetOtherDefaults(workspaceId, id);
}
const personality = await this.prisma.personality.update({
where: { id },
data: dto,
});
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
return personality;
}
/**
* Delete a personality
*/
async remove(workspaceId: string, id: string): Promise<Personality> {
// Check existence
await this.findOne(workspaceId, id);
const personality = await this.prisma.personality.delete({
where: { id },
});
this.logger.log(`Deleted personality ${id} from workspace ${workspaceId}`);
return personality;
}
/**
* Unset the default flag on all other personalities in the workspace
*/
private async unsetOtherDefaults(workspaceId: string, excludeId?: string): Promise<void> {
const currentDefault = await this.prisma.personality.findFirst({
where: {
workspaceId,
isDefault: true,
...(excludeId && { id: { not: excludeId } }),
},
});
if (currentDefault) {
await this.prisma.personality.update({
where: { id: currentDefault.id },
data: { isDefault: false },
});
}
}
}