Merge develop to resolve conflicts
This commit is contained in:
@@ -15,7 +15,7 @@ import { LayoutsModule } from "./layouts/layouts.module";
|
|||||||
import { KnowledgeModule } from "./knowledge/knowledge.module";
|
import { KnowledgeModule } from "./knowledge/knowledge.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
import { WebSocketModule } from "./websocket/websocket.module";
|
import { WebSocketModule } from "./websocket/websocket.module";
|
||||||
import { OllamaModule } from "./ollama/ollama.module";
|
import { LlmModule } from "./llm/llm.module";
|
||||||
import { BrainModule } from "./brain/brain.module";
|
import { BrainModule } from "./brain/brain.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -34,7 +34,7 @@ import { BrainModule } from "./brain/brain.module";
|
|||||||
KnowledgeModule,
|
KnowledgeModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
WebSocketModule,
|
WebSocketModule,
|
||||||
OllamaModule,
|
LlmModule,
|
||||||
BrainModule,
|
BrainModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
export { LinkResolutionService } from "./link-resolution.service";
|
export { LinkResolutionService } from "./link-resolution.service";
|
||||||
export type { ResolvedEntry } from "./link-resolution.service";
|
export type {
|
||||||
|
ResolvedEntry,
|
||||||
|
ResolvedLink,
|
||||||
|
Backlink,
|
||||||
|
} from "./link-resolution.service";
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ describe("LinkResolutionService", () => {
|
|||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
},
|
},
|
||||||
|
knowledgeLink: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -403,4 +406,186 @@ describe("LinkResolutionService", () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveLinksFromContent", () => {
|
||||||
|
it("should parse and resolve wiki links from content", async () => {
|
||||||
|
const content =
|
||||||
|
"Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
|
||||||
|
|
||||||
|
// Mock resolveLink for each target
|
||||||
|
mockPrismaService.knowledgeEntry.findFirst
|
||||||
|
.mockResolvedValueOnce({ id: "entry-1" }) // TypeScript Guide
|
||||||
|
.mockResolvedValueOnce({ id: "entry-2" }); // React Hooks
|
||||||
|
|
||||||
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].link.target).toBe("TypeScript Guide");
|
||||||
|
expect(result[0].entryId).toBe("entry-1");
|
||||||
|
expect(result[1].link.target).toBe("React Hooks");
|
||||||
|
expect(result[1].entryId).toBe("entry-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null entryId for unresolved links", async () => {
|
||||||
|
const content = "See [[Non-existent Page]] for details.";
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||||
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
||||||
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].link.target).toBe("Non-existent Page");
|
||||||
|
expect(result[0].entryId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array for content with no wiki links", async () => {
|
||||||
|
const content = "This content has no wiki links.";
|
||||||
|
|
||||||
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle content with display text syntax", async () => {
|
||||||
|
const content = "Read the [[typescript-guide|TS Guide]] first.";
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||||
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce({
|
||||||
|
id: "entry-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].link.target).toBe("typescript-guide");
|
||||||
|
expect(result[0].link.displayText).toBe("TS Guide");
|
||||||
|
expect(result[0].entryId).toBe("entry-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve link position information", async () => {
|
||||||
|
const content = "Start [[Page One]] middle [[Page Two]] end.";
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeEntry.findFirst
|
||||||
|
.mockResolvedValueOnce({ id: "entry-1" })
|
||||||
|
.mockResolvedValueOnce({ id: "entry-2" });
|
||||||
|
|
||||||
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].link.start).toBe(6);
|
||||||
|
expect(result[0].link.end).toBe(18);
|
||||||
|
expect(result[1].link.start).toBe(26);
|
||||||
|
expect(result[1].link.end).toBe(38);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBacklinks", () => {
|
||||||
|
it("should return all entries that link to the target entry", async () => {
|
||||||
|
const targetEntryId = "entry-target";
|
||||||
|
const mockBacklinks = [
|
||||||
|
{
|
||||||
|
id: "link-1",
|
||||||
|
sourceId: "entry-1",
|
||||||
|
targetId: targetEntryId,
|
||||||
|
linkText: "Target Page",
|
||||||
|
displayText: "Target Page",
|
||||||
|
positionStart: 10,
|
||||||
|
positionEnd: 25,
|
||||||
|
resolved: true,
|
||||||
|
context: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
source: {
|
||||||
|
id: "entry-1",
|
||||||
|
title: "TypeScript Guide",
|
||||||
|
slug: "typescript-guide",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "link-2",
|
||||||
|
sourceId: "entry-2",
|
||||||
|
targetId: targetEntryId,
|
||||||
|
linkText: "Target Page",
|
||||||
|
displayText: "See Target",
|
||||||
|
positionStart: 50,
|
||||||
|
positionEnd: 70,
|
||||||
|
resolved: true,
|
||||||
|
context: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
source: {
|
||||||
|
id: "entry-2",
|
||||||
|
title: "React Hooks",
|
||||||
|
slug: "react-hooks",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(
|
||||||
|
mockBacklinks
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getBacklinks(targetEntryId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
sourceId: "entry-1",
|
||||||
|
sourceTitle: "TypeScript Guide",
|
||||||
|
sourceSlug: "typescript-guide",
|
||||||
|
linkText: "Target Page",
|
||||||
|
displayText: "Target Page",
|
||||||
|
});
|
||||||
|
expect(result[1]).toEqual({
|
||||||
|
sourceId: "entry-2",
|
||||||
|
sourceTitle: "React Hooks",
|
||||||
|
sourceSlug: "react-hooks",
|
||||||
|
linkText: "Target Page",
|
||||||
|
displayText: "See Target",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
targetId: targetEntryId,
|
||||||
|
resolved: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
source: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array when no backlinks exist", async () => {
|
||||||
|
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await service.getBacklinks("entry-with-no-backlinks");
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only return resolved backlinks", async () => {
|
||||||
|
const targetEntryId = "entry-target";
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await service.getBacklinks(targetEntryId);
|
||||||
|
|
||||||
|
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
resolved: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
|
import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a knowledge entry that matches a link target
|
* Represents a knowledge entry that matches a link target
|
||||||
@@ -9,6 +10,32 @@ export interface ResolvedEntry {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a resolved wiki link with entry information
|
||||||
|
*/
|
||||||
|
export interface ResolvedLink {
|
||||||
|
/** The parsed wiki link */
|
||||||
|
link: WikiLink;
|
||||||
|
/** The resolved entry ID, or null if not found */
|
||||||
|
entryId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a backlink - an entry that links to a target entry
|
||||||
|
*/
|
||||||
|
export interface Backlink {
|
||||||
|
/** The source entry ID */
|
||||||
|
sourceId: string;
|
||||||
|
/** The source entry title */
|
||||||
|
sourceTitle: string;
|
||||||
|
/** The source entry slug */
|
||||||
|
sourceSlug: string;
|
||||||
|
/** The link text used to reference the target */
|
||||||
|
linkText: string;
|
||||||
|
/** The display text shown for the link */
|
||||||
|
displayText: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for resolving wiki-style links to knowledge entries
|
* Service for resolving wiki-style links to knowledge entries
|
||||||
*
|
*
|
||||||
@@ -165,4 +192,72 @@ export class LinkResolutionService {
|
|||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse wiki links from content and resolve them to knowledge entries
|
||||||
|
*
|
||||||
|
* @param content - The markdown content containing wiki links
|
||||||
|
* @param workspaceId - The workspace scope for resolution
|
||||||
|
* @returns Array of resolved links with entry IDs (or null if not found)
|
||||||
|
*/
|
||||||
|
async resolveLinksFromContent(
|
||||||
|
content: string,
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<ResolvedLink[]> {
|
||||||
|
// Parse wiki links from content
|
||||||
|
const parsedLinks = parseWikiLinks(content);
|
||||||
|
|
||||||
|
if (parsedLinks.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve each link
|
||||||
|
const resolvedLinks: ResolvedLink[] = [];
|
||||||
|
|
||||||
|
for (const link of parsedLinks) {
|
||||||
|
const entryId = await this.resolveLink(workspaceId, link.target);
|
||||||
|
resolvedLinks.push({
|
||||||
|
link,
|
||||||
|
entryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entries that link TO a specific entry (backlinks)
|
||||||
|
*
|
||||||
|
* @param entryId - The target entry ID
|
||||||
|
* @returns Array of backlinks with source entry information
|
||||||
|
*/
|
||||||
|
async getBacklinks(entryId: string): Promise<Backlink[]> {
|
||||||
|
// Find all links where this entry is the target
|
||||||
|
const links = await this.prisma.knowledgeLink.findMany({
|
||||||
|
where: {
|
||||||
|
targetId: entryId,
|
||||||
|
resolved: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
source: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return links.map((link) => ({
|
||||||
|
sourceId: link.source.id,
|
||||||
|
sourceTitle: link.source.title,
|
||||||
|
sourceSlug: link.source.slug,
|
||||||
|
linkText: link.linkText,
|
||||||
|
displayText: link.displayText,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/api/src/llm/dto/chat.dto.ts
Normal file
7
apps/api/src/llm/dto/chat.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsArray, IsString, IsOptional, IsBoolean, IsNumber, ValidateNested, IsIn } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
export type ChatRole = "system" | "user" | "assistant";
|
||||||
|
export class ChatMessageDto { @IsString() @IsIn(["system", "user", "assistant"]) role!: ChatRole; @IsString() content!: string; }
|
||||||
|
export class ChatRequestDto { @IsString() model!: string; @IsArray() @ValidateNested({ each: true }) @Type(() => ChatMessageDto) messages!: ChatMessageDto[]; @IsOptional() @IsBoolean() stream?: boolean; @IsOptional() @IsNumber() temperature?: number; @IsOptional() @IsNumber() maxTokens?: number; @IsOptional() @IsString() systemPrompt?: string; }
|
||||||
|
export interface ChatResponseDto { model: string; message: { role: ChatRole; content: string }; done: boolean; totalDuration?: number; promptEvalCount?: number; evalCount?: number; }
|
||||||
|
export interface ChatStreamChunkDto { model: string; message: { role: ChatRole; content: string }; done: boolean; }
|
||||||
3
apps/api/src/llm/dto/embed.dto.ts
Normal file
3
apps/api/src/llm/dto/embed.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { IsArray, IsString, IsOptional } from "class-validator";
|
||||||
|
export class EmbedRequestDto { @IsString() model!: string; @IsArray() @IsString({ each: true }) input!: string[]; @IsOptional() @IsString() truncate?: "start" | "end" | "none"; }
|
||||||
|
export interface EmbedResponseDto { model: string; embeddings: number[][]; totalDuration?: number; }
|
||||||
2
apps/api/src/llm/dto/index.ts
Normal file
2
apps/api/src/llm/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./chat.dto";
|
||||||
|
export * from "./embed.dto";
|
||||||
4
apps/api/src/llm/index.ts
Normal file
4
apps/api/src/llm/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./llm.module";
|
||||||
|
export * from "./llm.service";
|
||||||
|
export * from "./llm.controller";
|
||||||
|
export * from "./dto";
|
||||||
15
apps/api/src/llm/llm.controller.spec.ts
Normal file
15
apps/api/src/llm/llm.controller.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { LlmController } from "./llm.controller";
|
||||||
|
import { LlmService } from "./llm.service";
|
||||||
|
import type { ChatRequestDto, EmbedRequestDto } from "./dto";
|
||||||
|
describe("LlmController", () => {
|
||||||
|
let controller: LlmController;
|
||||||
|
const mockService = { checkHealth: vi.fn(), listModels: vi.fn(), chat: vi.fn(), chatStream: vi.fn(), embed: vi.fn() };
|
||||||
|
beforeEach(async () => { vi.clearAllMocks(); controller = (await Test.createTestingModule({ controllers: [LlmController], providers: [{ provide: LlmService, useValue: mockService }] }).compile()).get(LlmController); });
|
||||||
|
it("should be defined", () => { expect(controller).toBeDefined(); });
|
||||||
|
describe("health", () => { it("should return status", async () => { const s = { healthy: true, host: "h" }; mockService.checkHealth.mockResolvedValue(s); expect(await controller.health()).toEqual(s); }); });
|
||||||
|
describe("listModels", () => { it("should return models", async () => { mockService.listModels.mockResolvedValue(["m1"]); expect(await controller.listModels()).toEqual({ models: ["m1"] }); }); });
|
||||||
|
describe("chat", () => { const req: ChatRequestDto = { model: "m", messages: [{ role: "user", content: "x" }] }; const res = { setHeader: vi.fn(), write: vi.fn(), end: vi.fn() }; it("should return response", async () => { const r = { model: "m", message: { role: "assistant", content: "y" }, done: true }; mockService.chat.mockResolvedValue(r); expect(await controller.chat(req, res as any)).toEqual(r); }); it("should stream", async () => { mockService.chatStream.mockReturnValue((async function* () { yield { model: "m", message: { role: "a", content: "x" }, done: true }; })()); await controller.chat({ ...req, stream: true }, res as any); expect(res.setHeader).toHaveBeenCalled(); expect(res.end).toHaveBeenCalled(); }); });
|
||||||
|
describe("embed", () => { it("should return embeddings", async () => { const r = { model: "m", embeddings: [[0.1]] }; mockService.embed.mockResolvedValue(r); expect(await controller.embed({ model: "m", input: ["x"] })).toEqual(r); }); });
|
||||||
|
});
|
||||||
12
apps/api/src/llm/llm.controller.ts
Normal file
12
apps/api/src/llm/llm.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Post, Get, Body, Res, HttpCode, HttpStatus } from "@nestjs/common";
|
||||||
|
import { Response } from "express";
|
||||||
|
import { LlmService, OllamaHealthStatus } from "./llm.service";
|
||||||
|
import { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto } from "./dto";
|
||||||
|
@Controller("llm")
|
||||||
|
export class LlmController {
|
||||||
|
constructor(private readonly llmService: LlmService) {}
|
||||||
|
@Get("health") async health(): Promise<OllamaHealthStatus> { return this.llmService.checkHealth(); }
|
||||||
|
@Get("models") async listModels(): Promise<{ models: string[] }> { return { models: await this.llmService.listModels() }; }
|
||||||
|
@Post("chat") @HttpCode(HttpStatus.OK) async chat(@Body() req: ChatRequestDto, @Res({ passthrough: true }) res: Response): Promise<ChatResponseDto | void> { if (req.stream === true) { res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); try { for await (const c of this.llmService.chatStream(req)) res.write("data: " + JSON.stringify(c) + "\n\n"); res.write("data: [DONE]\n\n"); res.end(); } catch (e: unknown) { res.write("data: " + JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) + "\n\n"); res.end(); } return; } return this.llmService.chat(req); }
|
||||||
|
@Post("embed") @HttpCode(HttpStatus.OK) async embed(@Body() req: EmbedRequestDto): Promise<EmbedResponseDto> { return this.llmService.embed(req); }
|
||||||
|
}
|
||||||
5
apps/api/src/llm/llm.module.ts
Normal file
5
apps/api/src/llm/llm.module.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { LlmController } from "./llm.controller";
|
||||||
|
import { LlmService } from "./llm.service";
|
||||||
|
@Module({ controllers: [LlmController], providers: [LlmService], exports: [LlmService] })
|
||||||
|
export class LlmModule {}
|
||||||
19
apps/api/src/llm/llm.service.spec.ts
Normal file
19
apps/api/src/llm/llm.service.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import { LlmService } from "./llm.service";
|
||||||
|
import type { ChatRequestDto, EmbedRequestDto } from "./dto";
|
||||||
|
const mockList = vi.fn(); const mockChat = vi.fn(); const mockEmbed = vi.fn();
|
||||||
|
vi.mock("ollama", () => ({ Ollama: class { list = mockList; chat = mockChat; embed = mockEmbed; } }));
|
||||||
|
describe("LlmService", () => {
|
||||||
|
let service: LlmService;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
beforeEach(async () => { process.env = { ...originalEnv, OLLAMA_HOST: "http://test:11434", OLLAMA_TIMEOUT: "60000" }; vi.clearAllMocks(); service = (await Test.createTestingModule({ providers: [LlmService] }).compile()).get(LlmService); });
|
||||||
|
afterEach(() => { process.env = originalEnv; });
|
||||||
|
it("should be defined", () => { expect(service).toBeDefined(); });
|
||||||
|
describe("checkHealth", () => { it("should return healthy", async () => { mockList.mockResolvedValue({ models: [{ name: "llama3.2" }] }); const r = await service.checkHealth(); expect(r.healthy).toBe(true); }); it("should return unhealthy on error", async () => { mockList.mockRejectedValue(new Error("fail")); const r = await service.checkHealth(); expect(r.healthy).toBe(false); }); });
|
||||||
|
describe("listModels", () => { it("should return models", async () => { mockList.mockResolvedValue({ models: [{ name: "llama3.2" }] }); expect(await service.listModels()).toEqual(["llama3.2"]); }); it("should throw on error", async () => { mockList.mockRejectedValue(new Error("fail")); await expect(service.listModels()).rejects.toThrow(ServiceUnavailableException); }); });
|
||||||
|
describe("chat", () => { const req: ChatRequestDto = { model: "llama3.2", messages: [{ role: "user", content: "Hi" }] }; it("should return response", async () => { mockChat.mockResolvedValue({ model: "llama3.2", message: { role: "assistant", content: "Hello" }, done: true }); const r = await service.chat(req); expect(r.message.content).toBe("Hello"); }); it("should throw on error", async () => { mockChat.mockRejectedValue(new Error("fail")); await expect(service.chat(req)).rejects.toThrow(ServiceUnavailableException); }); });
|
||||||
|
describe("chatStream", () => { it("should yield chunks", async () => { mockChat.mockResolvedValue((async function* () { yield { model: "m", message: { role: "a", content: "x" }, done: true }; })()); const chunks = []; for await (const c of service.chatStream({ model: "m", messages: [{ role: "user", content: "x" }], stream: true })) chunks.push(c); expect(chunks.length).toBe(1); }); });
|
||||||
|
describe("embed", () => { it("should return embeddings", async () => { mockEmbed.mockResolvedValue({ model: "m", embeddings: [[0.1]] }); const r = await service.embed({ model: "m", input: ["x"] }); expect(r.embeddings).toEqual([[0.1]]); }); });
|
||||||
|
});
|
||||||
20
apps/api/src/llm/llm.service.ts
Normal file
20
apps/api/src/llm/llm.service.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable, OnModuleInit, Logger, ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import { Ollama, Message } from "ollama";
|
||||||
|
import type { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto, ChatStreamChunkDto } from "./dto";
|
||||||
|
export interface OllamaConfig { host: string; timeout?: number; }
|
||||||
|
export interface OllamaHealthStatus { healthy: boolean; host: string; error?: string; models?: string[]; }
|
||||||
|
@Injectable()
|
||||||
|
export class LlmService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(LlmService.name);
|
||||||
|
private client: Ollama;
|
||||||
|
private readonly config: OllamaConfig;
|
||||||
|
constructor() { this.config = { host: process.env["OLLAMA_HOST"] ?? "http://localhost:11434", timeout: parseInt(process.env["OLLAMA_TIMEOUT"] ?? "120000", 10) }; this.client = new Ollama({ host: this.config.host }); this.logger.log("Ollama service initialized"); }
|
||||||
|
async onModuleInit(): Promise<void> { const h = await this.checkHealth(); if (h.healthy) this.logger.log("Ollama healthy"); else this.logger.warn("Ollama unhealthy: " + (h.error ?? "unknown")); }
|
||||||
|
async checkHealth(): Promise<OllamaHealthStatus> { try { const r = await this.client.list(); return { healthy: true, host: this.config.host, models: r.models.map(m => m.name) }; } catch (e: unknown) { return { healthy: false, host: this.config.host, error: e instanceof Error ? e.message : String(e) }; } }
|
||||||
|
async listModels(): Promise<string[]> { try { return (await this.client.list()).models.map(m => m.name); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Failed to list models: " + msg); throw new ServiceUnavailableException("Failed to list models: " + msg); } }
|
||||||
|
async chat(request: ChatRequestDto): Promise<ChatResponseDto> { try { const msgs = this.buildMessages(request); const r = await this.client.chat({ model: request.model, messages: msgs, stream: false, options: { temperature: request.temperature, num_predict: request.maxTokens } }); return { model: r.model, message: { role: r.message.role as "assistant", content: r.message.content }, done: r.done, totalDuration: r.total_duration, promptEvalCount: r.prompt_eval_count, evalCount: r.eval_count }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Chat failed: " + msg); throw new ServiceUnavailableException("Chat completion failed: " + msg); } }
|
||||||
|
async *chatStream(request: ChatRequestDto): AsyncGenerator<ChatStreamChunkDto> { try { const stream = await this.client.chat({ model: request.model, messages: this.buildMessages(request), stream: true, options: { temperature: request.temperature, num_predict: request.maxTokens } }); for await (const c of stream) yield { model: c.model, message: { role: c.message.role as "assistant", content: c.message.content }, done: c.done }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Stream failed: " + msg); throw new ServiceUnavailableException("Streaming failed: " + msg); } }
|
||||||
|
async embed(request: EmbedRequestDto): Promise<EmbedResponseDto> { try { const r = await this.client.embed({ model: request.model, input: request.input, truncate: request.truncate === "none" ? false : true }); return { model: r.model, embeddings: r.embeddings, totalDuration: r.total_duration }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Embed failed: " + msg); throw new ServiceUnavailableException("Embedding failed: " + msg); } }
|
||||||
|
private buildMessages(req: ChatRequestDto): Message[] { const msgs: Message[] = []; if (req.systemPrompt && !req.messages.some(m => m.role === "system")) msgs.push({ role: "system", content: req.systemPrompt }); for (const m of req.messages) msgs.push({ role: m.role, content: m.content }); return msgs; }
|
||||||
|
getConfig(): OllamaConfig { return { ...this.config }; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,38 @@
|
|||||||
import { PartialType } from "@nestjs/mapped-types";
|
import { IsString, IsOptional, IsBoolean, MinLength, MaxLength, IsIn } from "class-validator";
|
||||||
import { CreatePersonalityDto } from "./create-personality.dto";
|
import { FORMALITY_LEVELS, FormalityLevelType } from "./create-personality.dto";
|
||||||
|
|
||||||
export class UpdatePersonalityDto extends PartialType(CreatePersonalityDto) {}
|
export class UpdatePersonalityDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(50)
|
||||||
|
tone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(FORMALITY_LEVELS)
|
||||||
|
formalityLevel?: FormalityLevelType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
systemPromptTemplate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,29 +2,24 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PersonalitiesController } from "./personalities.controller";
|
import { PersonalitiesController } from "./personalities.controller";
|
||||||
import { PersonalitiesService } from "./personalities.service";
|
import { PersonalitiesService } from "./personalities.service";
|
||||||
|
import { PromptFormatterService } from "./services/prompt-formatter.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
|
||||||
|
|
||||||
describe("PersonalitiesController", () => {
|
describe("PersonalitiesController", () => {
|
||||||
let controller: PersonalitiesController;
|
let controller: PersonalitiesController;
|
||||||
let service: PersonalitiesService;
|
let service: PersonalitiesService;
|
||||||
|
let promptFormatter: PromptFormatterService;
|
||||||
|
|
||||||
const mockWorkspaceId = "workspace-123";
|
const mockWorkspaceId = "workspace-123";
|
||||||
const mockUserId = "user-123";
|
|
||||||
const mockPersonalityId = "personality-123";
|
const mockPersonalityId = "personality-123";
|
||||||
|
const mockRequest = { user: { id: "user-123" }, workspaceId: mockWorkspaceId };
|
||||||
const mockRequest = {
|
|
||||||
user: { id: mockUserId },
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockPersonality = {
|
const mockPersonality = {
|
||||||
id: mockPersonalityId,
|
id: mockPersonalityId,
|
||||||
workspaceId: mockWorkspaceId,
|
workspaceId: mockWorkspaceId,
|
||||||
name: "Professional",
|
name: "Professional",
|
||||||
description: "Professional communication style",
|
|
||||||
tone: "professional",
|
tone: "professional",
|
||||||
formalityLevel: "FORMAL" as const,
|
formalityLevel: "FORMAL",
|
||||||
systemPromptTemplate: "You are a professional assistant.",
|
systemPromptTemplate: "You are a professional assistant.",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -41,105 +36,82 @@ describe("PersonalitiesController", () => {
|
|||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAuthGuard = {
|
const mockPromptFormatterService = {
|
||||||
canActivate: vi.fn().mockReturnValue(true),
|
formatPrompt: vi.fn(),
|
||||||
|
getFormalityLevels: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [PersonalitiesController],
|
controllers: [PersonalitiesController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{ provide: PersonalitiesService, useValue: mockPersonalitiesService },
|
||||||
provide: PersonalitiesService,
|
{ provide: PromptFormatterService, useValue: mockPromptFormatterService },
|
||||||
useValue: mockPersonalitiesService,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.overrideGuard(AuthGuard)
|
.overrideGuard(AuthGuard)
|
||||||
.useValue(mockAuthGuard)
|
.useValue({ canActivate: () => true })
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
||||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||||
|
promptFormatter = module.get<PromptFormatterService>(PromptFormatterService);
|
||||||
// Reset mocks
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findAll", () => {
|
describe("findAll", () => {
|
||||||
it("should return all personalities", async () => {
|
it("should return all personalities", async () => {
|
||||||
const mockPersonalities = [mockPersonality];
|
|
||||||
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
|
|
||||||
|
|
||||||
const result = await controller.findAll(mockRequest as any);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonalities);
|
|
||||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter by active status", async () => {
|
|
||||||
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
|
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
|
||||||
|
const result = await controller.findAll(mockRequest);
|
||||||
await controller.findAll(mockRequest as any, false);
|
expect(result).toEqual([mockPersonality]);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, undefined);
|
||||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findOne", () => {
|
describe("findOne", () => {
|
||||||
it("should return a personality by id", async () => {
|
it("should return a personality by id", async () => {
|
||||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||||
|
const result = await controller.findOne(mockRequest, mockPersonalityId);
|
||||||
const result = await controller.findOne(mockRequest as any, mockPersonalityId);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
expect(result).toEqual(mockPersonality);
|
||||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findDefault", () => {
|
describe("findDefault", () => {
|
||||||
it("should return the default personality", async () => {
|
it("should return the default personality", async () => {
|
||||||
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
||||||
|
const result = await controller.findDefault(mockRequest);
|
||||||
const result = await controller.findDefault(mockRequest as any);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
expect(result).toEqual(mockPersonality);
|
||||||
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFormalityLevels", () => {
|
||||||
|
it("should return formality levels", () => {
|
||||||
|
const levels = [{ level: "FORMAL", description: "Professional" }];
|
||||||
|
mockPromptFormatterService.getFormalityLevels.mockReturnValue(levels);
|
||||||
|
const result = controller.getFormalityLevels();
|
||||||
|
expect(result).toEqual(levels);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("create", () => {
|
describe("create", () => {
|
||||||
const createDto: CreatePersonalityDto = {
|
|
||||||
name: "Casual",
|
|
||||||
description: "Casual communication style",
|
|
||||||
tone: "casual",
|
|
||||||
formalityLevel: "CASUAL",
|
|
||||||
systemPromptTemplate: "You are a casual assistant.",
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should create a new personality", async () => {
|
it("should create a new personality", async () => {
|
||||||
const newPersonality = { ...mockPersonality, ...createDto, id: "new-id" };
|
const createDto = {
|
||||||
mockPersonalitiesService.create.mockResolvedValue(newPersonality);
|
name: "Casual",
|
||||||
|
tone: "casual",
|
||||||
const result = await controller.create(mockRequest as any, createDto);
|
formalityLevel: "CASUAL" as const,
|
||||||
|
systemPromptTemplate: "You are a casual assistant.",
|
||||||
expect(result).toEqual(newPersonality);
|
};
|
||||||
|
mockPersonalitiesService.create.mockResolvedValue({ ...mockPersonality, ...createDto });
|
||||||
|
await controller.create(mockRequest, createDto);
|
||||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
const updateDto: UpdatePersonalityDto = {
|
|
||||||
description: "Updated description",
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should update a personality", async () => {
|
it("should update a personality", async () => {
|
||||||
const updatedPersonality = { ...mockPersonality, ...updateDto };
|
const updateDto = { description: "Updated" };
|
||||||
mockPersonalitiesService.update.mockResolvedValue(updatedPersonality);
|
mockPersonalitiesService.update.mockResolvedValue({ ...mockPersonality, ...updateDto });
|
||||||
|
await controller.update(mockRequest, mockPersonalityId, updateDto);
|
||||||
const result = await controller.update(mockRequest as any, mockPersonalityId, updateDto);
|
|
||||||
|
|
||||||
expect(result).toEqual(updatedPersonality);
|
|
||||||
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -147,11 +119,21 @@ describe("PersonalitiesController", () => {
|
|||||||
describe("remove", () => {
|
describe("remove", () => {
|
||||||
it("should delete a personality", async () => {
|
it("should delete a personality", async () => {
|
||||||
mockPersonalitiesService.remove.mockResolvedValue(mockPersonality);
|
mockPersonalitiesService.remove.mockResolvedValue(mockPersonality);
|
||||||
|
await controller.remove(mockRequest, mockPersonalityId);
|
||||||
const result = await controller.remove(mockRequest as any, mockPersonalityId);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
|
||||||
expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("previewPrompt", () => {
|
||||||
|
it("should return formatted system prompt", async () => {
|
||||||
|
const context = { userName: "John" };
|
||||||
|
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||||
|
mockPromptFormatterService.formatPrompt.mockReturnValue({
|
||||||
|
systemPrompt: "Formatted prompt",
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
const result = await controller.previewPrompt(mockRequest, mockPersonalityId, context);
|
||||||
|
expect(result.systemPrompt).toBe("Formatted prompt");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,69 +9,81 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Req,
|
Req,
|
||||||
|
ParseBoolPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { PersonalitiesService } from "./personalities.service";
|
import { PersonalitiesService } from "./personalities.service";
|
||||||
|
import { PromptFormatterService, PromptContext } from "./services/prompt-formatter.service";
|
||||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||||
import { Personality } from "./entities/personality.entity";
|
import { Personality } from "./entities/personality.entity";
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: { id: string };
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller("personalities")
|
@Controller("personalities")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class PersonalitiesController {
|
export class PersonalitiesController {
|
||||||
constructor(private readonly personalitiesService: PersonalitiesService) {}
|
constructor(
|
||||||
|
private readonly personalitiesService: PersonalitiesService,
|
||||||
|
private readonly promptFormatter: PromptFormatterService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all personalities for the current workspace
|
|
||||||
*/
|
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(
|
async findAll(
|
||||||
@Req() req: any,
|
@Req() req: AuthenticatedRequest,
|
||||||
@Query("isActive") isActive: boolean = true,
|
@Query("isActive", new ParseBoolPipe({ optional: true })) isActive?: boolean,
|
||||||
): Promise<Personality[]> {
|
): Promise<Personality[]> {
|
||||||
return this.personalitiesService.findAll(req.workspaceId, isActive);
|
return this.personalitiesService.findAll(req.workspaceId, isActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default personality for the current workspace
|
|
||||||
*/
|
|
||||||
@Get("default")
|
@Get("default")
|
||||||
async findDefault(@Req() req: any): Promise<Personality> {
|
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
|
||||||
return this.personalitiesService.findDefault(req.workspaceId);
|
return this.personalitiesService.findDefault(req.workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Get("formality-levels")
|
||||||
* Get a specific personality by ID
|
getFormalityLevels(): Array<{ level: string; description: string }> {
|
||||||
*/
|
return this.promptFormatter.getFormalityLevels();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
async findOne(@Req() req: any, @Param("id") id: string): Promise<Personality> {
|
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
|
||||||
return this.personalitiesService.findOne(req.workspaceId, id);
|
return this.personalitiesService.findOne(req.workspaceId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new personality
|
|
||||||
*/
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Req() req: any, @Body() dto: CreatePersonalityDto): Promise<Personality> {
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreatePersonalityDto): Promise<Personality> {
|
||||||
return this.personalitiesService.create(req.workspaceId, dto);
|
return this.personalitiesService.create(req.workspaceId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing personality
|
|
||||||
*/
|
|
||||||
@Put(":id")
|
@Put(":id")
|
||||||
async update(
|
async update(
|
||||||
@Req() req: any,
|
@Req() req: AuthenticatedRequest,
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Body() dto: UpdatePersonalityDto,
|
@Body() dto: UpdatePersonalityDto,
|
||||||
): Promise<Personality> {
|
): Promise<Personality> {
|
||||||
return this.personalitiesService.update(req.workspaceId, id, dto);
|
return this.personalitiesService.update(req.workspaceId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a personality
|
|
||||||
*/
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
async remove(@Req() req: any, @Param("id") id: string): Promise<Personality> {
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async remove(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
|
||||||
return this.personalitiesService.remove(req.workspaceId, id);
|
return this.personalitiesService.remove(req.workspaceId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/preview")
|
||||||
|
async previewPrompt(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() context?: PromptContext,
|
||||||
|
): Promise<{ systemPrompt: string }> {
|
||||||
|
const personality = await this.personalitiesService.findOne(req.workspaceId, id);
|
||||||
|
const { systemPrompt } = this.promptFormatter.formatPrompt(personality, context);
|
||||||
|
return { systemPrompt };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { PrismaModule } from "../prisma/prisma.module";
|
|||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { PersonalitiesService } from "./personalities.service";
|
import { PersonalitiesService } from "./personalities.service";
|
||||||
import { PersonalitiesController } from "./personalities.controller";
|
import { PersonalitiesController } from "./personalities.controller";
|
||||||
|
import { PromptFormatterService } from "./services/prompt-formatter.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, AuthModule],
|
imports: [PrismaModule, AuthModule],
|
||||||
controllers: [PersonalitiesController],
|
controllers: [PersonalitiesController],
|
||||||
providers: [PersonalitiesService],
|
providers: [PersonalitiesService, PromptFormatterService],
|
||||||
exports: [PersonalitiesService],
|
exports: [PersonalitiesService, PromptFormatterService],
|
||||||
})
|
})
|
||||||
export class PersonalitiesModule {}
|
export class PersonalitiesModule {}
|
||||||
|
|||||||
1
apps/api/src/personalities/services/index.ts
Normal file
1
apps/api/src/personalities/services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./prompt-formatter.service";
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { PromptFormatterService, PromptContext } from "./prompt-formatter.service";
|
||||||
|
|
||||||
|
describe("PromptFormatterService", () => {
|
||||||
|
let service: PromptFormatterService;
|
||||||
|
|
||||||
|
const mockPersonality = {
|
||||||
|
id: "personality-123",
|
||||||
|
workspaceId: "workspace-123",
|
||||||
|
name: "Professional",
|
||||||
|
description: "Professional communication style",
|
||||||
|
tone: "professional",
|
||||||
|
formalityLevel: "FORMAL" as const,
|
||||||
|
systemPromptTemplate: "You are a helpful assistant for {{userName}} at {{workspaceName}}.",
|
||||||
|
isDefault: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [PromptFormatterService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<PromptFormatterService>(PromptFormatterService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatPrompt", () => {
|
||||||
|
it("should format prompt with context variables", () => {
|
||||||
|
const context: PromptContext = { userName: "John", workspaceName: "Acme Corp" };
|
||||||
|
const result = service.formatPrompt(mockPersonality, context);
|
||||||
|
expect(result.systemPrompt).toContain("John");
|
||||||
|
expect(result.systemPrompt).toContain("Acme Corp");
|
||||||
|
expect(result.metadata.personalityId).toBe(mockPersonality.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add formality modifier", () => {
|
||||||
|
const result = service.formatPrompt(mockPersonality);
|
||||||
|
expect(result.systemPrompt).toContain("professional");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include metadata", () => {
|
||||||
|
const result = service.formatPrompt(mockPersonality);
|
||||||
|
expect(result.metadata.formattedAt).toBeInstanceOf(Date);
|
||||||
|
expect(result.metadata.tone).toBe("professional");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom context variables", () => {
|
||||||
|
const personality = { ...mockPersonality, systemPromptTemplate: "Custom: {{customVar}}" };
|
||||||
|
const context: PromptContext = { custom: { customVar: "test-value" } };
|
||||||
|
const result = service.formatPrompt(personality, context);
|
||||||
|
expect(result.systemPrompt).toContain("test-value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSystemPrompt", () => {
|
||||||
|
it("should build complete prompt with context", () => {
|
||||||
|
const context: PromptContext = { userName: "Jane" };
|
||||||
|
const result = service.buildSystemPrompt(mockPersonality, context);
|
||||||
|
expect(result).toContain("Jane");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include date/time when requested", () => {
|
||||||
|
const context: PromptContext = { currentDate: "2024-01-29", currentTime: "14:30", timezone: "UTC" };
|
||||||
|
const result = service.buildSystemPrompt(mockPersonality, context, { includeDateTime: true });
|
||||||
|
expect(result).toContain("2024-01-29");
|
||||||
|
expect(result).toContain("14:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include additional instructions", () => {
|
||||||
|
const result = service.buildSystemPrompt(mockPersonality, undefined, {
|
||||||
|
additionalInstructions: "Be concise.",
|
||||||
|
});
|
||||||
|
expect(result).toContain("Be concise.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateTemplate", () => {
|
||||||
|
it("should validate known variables", () => {
|
||||||
|
const template = "Hello {{userName}}, welcome to {{workspaceName}}!";
|
||||||
|
const result = service.validateTemplate(template);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report unknown variables", () => {
|
||||||
|
const template = "Hello {{unknownVar}}!";
|
||||||
|
const result = service.validateTemplate(template);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.missingVariables).toContain("unknownVar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow custom_ prefixed variables", () => {
|
||||||
|
const template = "Value: {{custom_myVar}}";
|
||||||
|
const result = service.validateTemplate(template);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFormalityLevels", () => {
|
||||||
|
it("should return all formality levels", () => {
|
||||||
|
const levels = service.getFormalityLevels();
|
||||||
|
expect(levels).toHaveLength(5);
|
||||||
|
expect(levels.map((l) => l.level)).toContain("FORMAL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
137
apps/api/src/personalities/services/prompt-formatter.service.ts
Normal file
137
apps/api/src/personalities/services/prompt-formatter.service.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { FormalityLevel } from "@prisma/client";
|
||||||
|
import { Personality } from "../entities/personality.entity";
|
||||||
|
|
||||||
|
export interface PromptContext {
|
||||||
|
userName?: string;
|
||||||
|
workspaceName?: string;
|
||||||
|
currentDate?: string;
|
||||||
|
currentTime?: string;
|
||||||
|
timezone?: string;
|
||||||
|
custom?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormattedPrompt {
|
||||||
|
systemPrompt: string;
|
||||||
|
metadata: {
|
||||||
|
personalityId: string;
|
||||||
|
personalityName: string;
|
||||||
|
tone: string;
|
||||||
|
formalityLevel: FormalityLevel;
|
||||||
|
formattedAt: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMALITY_MODIFIERS: Record<FormalityLevel, string> = {
|
||||||
|
VERY_CASUAL: "Be extremely relaxed and friendly. Use casual language, contractions, and even emojis when appropriate.",
|
||||||
|
CASUAL: "Be friendly and approachable. Use conversational language and a warm tone.",
|
||||||
|
NEUTRAL: "Be professional yet approachable. Balance formality with friendliness.",
|
||||||
|
FORMAL: "Be professional and respectful. Use proper grammar and formal language.",
|
||||||
|
VERY_FORMAL: "Be highly professional and formal. Use precise language and maintain a respectful, business-like demeanor.",
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PromptFormatterService {
|
||||||
|
formatPrompt(personality: Personality, context?: PromptContext): FormattedPrompt {
|
||||||
|
let prompt = personality.systemPromptTemplate;
|
||||||
|
prompt = this.interpolateVariables(prompt, context);
|
||||||
|
|
||||||
|
if (!prompt.toLowerCase().includes("formality") && !prompt.toLowerCase().includes(personality.formalityLevel.toLowerCase())) {
|
||||||
|
const modifier = FORMALITY_MODIFIERS[personality.formalityLevel];
|
||||||
|
prompt = `${prompt}\n\n${modifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prompt.toLowerCase().includes(personality.tone.toLowerCase())) {
|
||||||
|
prompt = `${prompt}\n\nMaintain a ${personality.tone} tone throughout the conversation.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
systemPrompt: prompt.trim(),
|
||||||
|
metadata: {
|
||||||
|
personalityId: personality.id,
|
||||||
|
personalityName: personality.name,
|
||||||
|
tone: personality.tone,
|
||||||
|
formalityLevel: personality.formalityLevel,
|
||||||
|
formattedAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSystemPrompt(
|
||||||
|
personality: Personality,
|
||||||
|
context?: PromptContext,
|
||||||
|
options?: { includeDateTime?: boolean; additionalInstructions?: string },
|
||||||
|
): string {
|
||||||
|
const { systemPrompt } = this.formatPrompt(personality, context);
|
||||||
|
const parts: string[] = [systemPrompt];
|
||||||
|
|
||||||
|
if (options?.includeDateTime === true) {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = context?.currentDate ?? now.toISOString().split("T")[0];
|
||||||
|
const timeStr = context?.currentTime ?? now.toTimeString().slice(0, 5);
|
||||||
|
const tzStr = context?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
parts.push(`Current date: ${dateStr}, Time: ${timeStr} (${tzStr})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.additionalInstructions !== undefined && options.additionalInstructions.length > 0) {
|
||||||
|
parts.push(options.additionalInstructions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private interpolateVariables(template: string, context?: PromptContext): string {
|
||||||
|
if (context === undefined) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = template;
|
||||||
|
|
||||||
|
if (context.userName !== undefined) {
|
||||||
|
result = result.replace(/\{\{userName\}\}/g, context.userName);
|
||||||
|
}
|
||||||
|
if (context.workspaceName !== undefined) {
|
||||||
|
result = result.replace(/\{\{workspaceName\}\}/g, context.workspaceName);
|
||||||
|
}
|
||||||
|
if (context.currentDate !== undefined) {
|
||||||
|
result = result.replace(/\{\{currentDate\}\}/g, context.currentDate);
|
||||||
|
}
|
||||||
|
if (context.currentTime !== undefined) {
|
||||||
|
result = result.replace(/\{\{currentTime\}\}/g, context.currentTime);
|
||||||
|
}
|
||||||
|
if (context.timezone !== undefined) {
|
||||||
|
result = result.replace(/\{\{timezone\}\}/g, context.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.custom !== undefined) {
|
||||||
|
for (const [key, value] of Object.entries(context.custom)) {
|
||||||
|
const regex = new RegExp(`\\{\\{${key}\\}\\}`, "g");
|
||||||
|
result = result.replace(regex, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateTemplate(template: string): { valid: boolean; missingVariables: string[] } {
|
||||||
|
const variablePattern = /\{\{(\w+)\}\}/g;
|
||||||
|
const matches = template.matchAll(variablePattern);
|
||||||
|
const variables = Array.from(matches, (m) => m[1]);
|
||||||
|
|
||||||
|
const allowedVariables = new Set(["userName", "workspaceName", "currentDate", "currentTime", "timezone"]);
|
||||||
|
|
||||||
|
const unknownVariables = variables.filter((v) => !allowedVariables.has(v) && !v.startsWith("custom_"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: unknownVariables.length === 0,
|
||||||
|
missingVariables: unknownVariables,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormalityLevels(): Array<{ level: FormalityLevel; description: string }> {
|
||||||
|
return Object.entries(FORMALITY_MODIFIERS).map(([level, description]) => ({
|
||||||
|
level: level as FormalityLevel,
|
||||||
|
description,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,6 +135,15 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
|||||||
this.logger.debug(`Emitted event:deleted to ${room}`);
|
this.logger.debug(`Emitted event:deleted to ${room}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit project:created event to workspace room
|
||||||
|
*/
|
||||||
|
emitProjectCreated(workspaceId: string, project: Project): void {
|
||||||
|
const room = this.getWorkspaceRoom(workspaceId);
|
||||||
|
this.server.to(room).emit('project:created', project);
|
||||||
|
this.logger.debug(`Emitted project:created to ${room}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit project:updated event to workspace room
|
* Emit project:updated event to workspace room
|
||||||
*/
|
*/
|
||||||
@@ -144,6 +153,15 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
|||||||
this.logger.debug(`Emitted project:updated to ${room}`);
|
this.logger.debug(`Emitted project:updated to ${room}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit project:deleted event to workspace room
|
||||||
|
*/
|
||||||
|
emitProjectDeleted(workspaceId: string, projectId: string): void {
|
||||||
|
const room = this.getWorkspaceRoom(workspaceId);
|
||||||
|
this.server.to(room).emit('project:deleted', { id: projectId });
|
||||||
|
this.logger.debug(`Emitted project:deleted to ${room}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get workspace room name
|
* Get workspace room name
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ describe("GanttChart", () => {
|
|||||||
render(<GanttChart tasks={tasks} />);
|
render(<GanttChart tasks={tasks} />);
|
||||||
|
|
||||||
const taskRow = screen.getAllByText("Completed Task")[0].closest("[role='row']");
|
const taskRow = screen.getAllByText("Completed Task")[0].closest("[role='row']");
|
||||||
expect(taskRow).toHaveClass(/completed/i);
|
expect(taskRow?.className).toMatch(/Completed/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should visually distinguish in-progress tasks", () => {
|
it("should visually distinguish in-progress tasks", () => {
|
||||||
@@ -106,7 +106,7 @@ describe("GanttChart", () => {
|
|||||||
render(<GanttChart tasks={tasks} />);
|
render(<GanttChart tasks={tasks} />);
|
||||||
|
|
||||||
const taskRow = screen.getAllByText("Active Task")[0].closest("[role='row']");
|
const taskRow = screen.getAllByText("Active Task")[0].closest("[role='row']");
|
||||||
expect(taskRow).toHaveClass(/in-progress/i);
|
expect(taskRow?.className).toMatch(/InProgress/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -354,28 +354,36 @@ describe("GanttChart", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Dependencies (stretch goal)", () => {
|
describe("Dependencies", () => {
|
||||||
it("should render dependency lines when showDependencies is true", () => {
|
it("should render dependency lines when showDependencies is true", () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createGanttTask({
|
createGanttTask({
|
||||||
id: "task-1",
|
id: "task-1",
|
||||||
title: "Foundation",
|
title: "Foundation",
|
||||||
|
startDate: new Date("2026-02-01"),
|
||||||
|
endDate: new Date("2026-02-10"),
|
||||||
}),
|
}),
|
||||||
createGanttTask({
|
createGanttTask({
|
||||||
id: "task-2",
|
id: "task-2",
|
||||||
title: "Build on top",
|
title: "Build on top",
|
||||||
|
startDate: new Date("2026-02-11"),
|
||||||
|
endDate: new Date("2026-02-20"),
|
||||||
dependencies: ["task-1"],
|
dependencies: ["task-1"],
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
render(<GanttChart tasks={tasks} showDependencies={true} />);
|
render(<GanttChart tasks={tasks} showDependencies={true} />);
|
||||||
|
|
||||||
// Check if dependency visualization exists
|
// Check if dependency SVG exists
|
||||||
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
||||||
expect(chart).toBeInTheDocument();
|
expect(chart).toBeInTheDocument();
|
||||||
|
|
||||||
// Specific dependency rendering will depend on implementation
|
// Look for dependency path element
|
||||||
// This is a basic check that the prop is accepted
|
const svg = chart.querySelector(".gantt-dependencies");
|
||||||
|
expect(svg).toBeInTheDocument();
|
||||||
|
|
||||||
|
const paths = chart.querySelectorAll(".dependency-line");
|
||||||
|
expect(paths).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not render dependencies by default", () => {
|
it("should not render dependencies by default", () => {
|
||||||
@@ -396,6 +404,138 @@ describe("GanttChart", () => {
|
|||||||
// Dependencies should not be shown by default
|
// Dependencies should not be shown by default
|
||||||
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
||||||
expect(chart).toBeInTheDocument();
|
expect(chart).toBeInTheDocument();
|
||||||
|
|
||||||
|
const svg = chart.querySelector(".gantt-dependencies");
|
||||||
|
expect(svg).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tasks with non-existent dependencies gracefully", () => {
|
||||||
|
const tasks = [
|
||||||
|
createGanttTask({
|
||||||
|
id: "task-1",
|
||||||
|
title: "Task 1",
|
||||||
|
dependencies: ["non-existent-task"],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<GanttChart tasks={tasks} showDependencies={true} />);
|
||||||
|
|
||||||
|
// Should not crash
|
||||||
|
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
||||||
|
expect(chart).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render multiple dependency lines", () => {
|
||||||
|
const tasks = [
|
||||||
|
createGanttTask({
|
||||||
|
id: "task-1",
|
||||||
|
title: "Task 1",
|
||||||
|
startDate: new Date("2026-02-01"),
|
||||||
|
endDate: new Date("2026-02-05"),
|
||||||
|
}),
|
||||||
|
createGanttTask({
|
||||||
|
id: "task-2",
|
||||||
|
title: "Task 2",
|
||||||
|
startDate: new Date("2026-02-01"),
|
||||||
|
endDate: new Date("2026-02-05"),
|
||||||
|
}),
|
||||||
|
createGanttTask({
|
||||||
|
id: "task-3",
|
||||||
|
title: "Task 3",
|
||||||
|
startDate: new Date("2026-02-06"),
|
||||||
|
endDate: new Date("2026-02-10"),
|
||||||
|
dependencies: ["task-1", "task-2"],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<GanttChart tasks={tasks} showDependencies={true} />);
|
||||||
|
|
||||||
|
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
||||||
|
const paths = chart.querySelectorAll(".dependency-line");
|
||||||
|
expect(paths).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Milestones", () => {
|
||||||
|
it("should render milestone as diamond shape", () => {
|
||||||
|
const milestone = createGanttTask({
|
||||||
|
id: "milestone-1",
|
||||||
|
title: "Phase 1 Complete",
|
||||||
|
startDate: new Date("2026-02-15"),
|
||||||
|
endDate: new Date("2026-02-15"),
|
||||||
|
isMilestone: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<GanttChart tasks={[milestone]} />);
|
||||||
|
|
||||||
|
const milestoneElement = screen.getByRole("button", {
|
||||||
|
name: /milestone.*phase 1 complete/i,
|
||||||
|
});
|
||||||
|
expect(milestoneElement).toBeInTheDocument();
|
||||||
|
expect(milestoneElement).toHaveClass("gantt-milestone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle click on milestone", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onTaskClick = vi.fn();
|
||||||
|
|
||||||
|
const milestone = createGanttTask({
|
||||||
|
id: "milestone-1",
|
||||||
|
title: "Milestone Task",
|
||||||
|
isMilestone: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<GanttChart tasks={[milestone]} onTaskClick={onTaskClick} />);
|
||||||
|
|
||||||
|
const milestoneElement = screen.getByRole("button", {
|
||||||
|
name: /milestone.*milestone task/i,
|
||||||
|
});
|
||||||
|
await user.click(milestoneElement);
|
||||||
|
|
||||||
|
expect(onTaskClick).toHaveBeenCalledWith(milestone);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support keyboard navigation for milestones", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onTaskClick = vi.fn();
|
||||||
|
|
||||||
|
const milestone = createGanttTask({
|
||||||
|
id: "milestone-1",
|
||||||
|
title: "Keyboard Milestone",
|
||||||
|
isMilestone: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<GanttChart tasks={[milestone]} onTaskClick={onTaskClick} />);
|
||||||
|
|
||||||
|
const milestoneElement = screen.getByRole("button", {
|
||||||
|
name: /milestone/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.tab();
|
||||||
|
expect(milestoneElement).toHaveFocus();
|
||||||
|
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
expect(onTaskClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render milestones and regular tasks together", () => {
|
||||||
|
const tasks = [
|
||||||
|
createGanttTask({
|
||||||
|
id: "task-1",
|
||||||
|
title: "Regular Task",
|
||||||
|
isMilestone: false,
|
||||||
|
}),
|
||||||
|
createGanttTask({
|
||||||
|
id: "milestone-1",
|
||||||
|
title: "Milestone",
|
||||||
|
isMilestone: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<GanttChart tasks={tasks} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /gantt bar.*regular task/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /milestone.*milestone/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,20 @@
|
|||||||
import type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types";
|
import type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types";
|
||||||
import { TaskStatus } from "@mosaic/shared";
|
import { TaskStatus } from "@mosaic/shared";
|
||||||
import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format";
|
import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
|
import styles from "./gantt.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a dependency line between two tasks
|
||||||
|
*/
|
||||||
|
interface DependencyLine {
|
||||||
|
fromTaskId: string;
|
||||||
|
toTaskId: string;
|
||||||
|
fromX: number;
|
||||||
|
fromY: number;
|
||||||
|
toX: number;
|
||||||
|
toY: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the timeline range from a list of tasks
|
* Calculate the timeline range from a list of tasks
|
||||||
@@ -99,11 +112,11 @@ function getStatusClass(status: TaskStatus): string {
|
|||||||
function getRowStatusClass(status: TaskStatus): string {
|
function getRowStatusClass(status: TaskStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case TaskStatus.COMPLETED:
|
case TaskStatus.COMPLETED:
|
||||||
return "gantt-row-completed";
|
return styles.rowCompleted;
|
||||||
case TaskStatus.IN_PROGRESS:
|
case TaskStatus.IN_PROGRESS:
|
||||||
return "gantt-row-in-progress";
|
return styles.rowInProgress;
|
||||||
case TaskStatus.PAUSED:
|
case TaskStatus.PAUSED:
|
||||||
return "gantt-row-paused";
|
return styles.rowPaused;
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -135,6 +148,65 @@ function generateTimelineLabels(range: TimelineRange): Array<{ label: string; po
|
|||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate dependency lines between tasks
|
||||||
|
*/
|
||||||
|
function calculateDependencyLines(
|
||||||
|
tasks: GanttTask[],
|
||||||
|
timelineRange: TimelineRange
|
||||||
|
): DependencyLine[] {
|
||||||
|
const lines: DependencyLine[] = [];
|
||||||
|
const taskIndexMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// Build index map
|
||||||
|
tasks.forEach((task, index) => {
|
||||||
|
taskIndexMap.set(task.id, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { start: rangeStart, totalDays } = timelineRange;
|
||||||
|
|
||||||
|
tasks.forEach((task, toIndex) => {
|
||||||
|
if (!task.dependencies || task.dependencies.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
task.dependencies.forEach((depId) => {
|
||||||
|
const fromIndex = taskIndexMap.get(depId);
|
||||||
|
if (fromIndex === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromTask = tasks[fromIndex];
|
||||||
|
|
||||||
|
// Calculate positions (as percentages)
|
||||||
|
const fromEndOffset = Math.max(
|
||||||
|
0,
|
||||||
|
(fromTask.endDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
const toStartOffset = Math.max(
|
||||||
|
0,
|
||||||
|
(task.startDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
const fromX = (fromEndOffset / totalDays) * 100;
|
||||||
|
const toX = (toStartOffset / totalDays) * 100;
|
||||||
|
const fromY = fromIndex * 48 + 24; // Center of the row
|
||||||
|
const toY = toIndex * 48 + 24;
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
fromTaskId: depId,
|
||||||
|
toTaskId: task.id,
|
||||||
|
fromX,
|
||||||
|
fromY,
|
||||||
|
toX,
|
||||||
|
toY,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main Gantt Chart Component
|
* Main Gantt Chart Component
|
||||||
*/
|
*/
|
||||||
@@ -155,20 +227,32 @@ export function GanttChart({
|
|||||||
// Generate timeline labels
|
// Generate timeline labels
|
||||||
const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]);
|
const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]);
|
||||||
|
|
||||||
const handleTaskClick = (task: GanttTask) => (): void => {
|
// Calculate dependency lines
|
||||||
if (onTaskClick) {
|
const dependencyLines = useMemo(
|
||||||
onTaskClick(task);
|
() => (showDependencies ? calculateDependencyLines(sortedTasks, timelineRange) : []),
|
||||||
}
|
[showDependencies, sortedTasks, timelineRange]
|
||||||
};
|
);
|
||||||
|
|
||||||
const handleKeyDown = (task: GanttTask) => (e: React.KeyboardEvent<HTMLDivElement>): void => {
|
const handleTaskClick = useCallback(
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
(task: GanttTask) => (): void => {
|
||||||
e.preventDefault();
|
|
||||||
if (onTaskClick) {
|
if (onTaskClick) {
|
||||||
onTaskClick(task);
|
onTaskClick(task);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[onTaskClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(task: GanttTask) => (e: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (onTaskClick) {
|
||||||
|
onTaskClick(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onTaskClick]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -242,11 +326,68 @@ export function GanttChart({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task bars */}
|
{/* Dependency lines SVG */}
|
||||||
|
{showDependencies && dependencyLines.length > 0 && (
|
||||||
|
<svg
|
||||||
|
className="gantt-dependencies absolute inset-0 pointer-events-none overflow-visible"
|
||||||
|
style={{ width: "100%", height: `${sortedTasks.length * 48}px` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="arrowhead"
|
||||||
|
markerWidth="10"
|
||||||
|
markerHeight="7"
|
||||||
|
refX="9"
|
||||||
|
refY="3.5"
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{dependencyLines.map((line) => (
|
||||||
|
<path
|
||||||
|
key={`dep-${line.fromTaskId}-${line.toTaskId}`}
|
||||||
|
d={`M ${line.fromX}% ${line.fromY} C ${line.fromX + 2}% ${line.fromY}, ${line.toX - 2}% ${line.toY}, ${line.toX}% ${line.toY}`}
|
||||||
|
stroke="#6b7280"
|
||||||
|
strokeWidth="2"
|
||||||
|
fill="none"
|
||||||
|
markerEnd="url(#arrowhead)"
|
||||||
|
className="dependency-line"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Task bars and milestones */}
|
||||||
{sortedTasks.map((task, index) => {
|
{sortedTasks.map((task, index) => {
|
||||||
const position = calculateBarPosition(task, timelineRange, index);
|
const position = calculateBarPosition(task, timelineRange, index);
|
||||||
const statusClass = getStatusClass(task.status);
|
const statusClass = getStatusClass(task.status);
|
||||||
|
|
||||||
|
// Render milestone as diamond shape
|
||||||
|
if (task.isMilestone === true) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Milestone: ${task.title}, on ${formatDate(task.startDate)}`}
|
||||||
|
className="gantt-milestone absolute cursor-pointer transition-all hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
style={{
|
||||||
|
left: position.left,
|
||||||
|
top: `${position.top + 8}px`,
|
||||||
|
}}
|
||||||
|
onClick={handleTaskClick(task)}
|
||||||
|
onKeyDown={handleKeyDown(task)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-6 h-6 transform rotate-45 ${statusClass}`}
|
||||||
|
title={task.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
@@ -281,19 +422,6 @@ export function GanttChart({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CSS for status classes */}
|
|
||||||
<style jsx>{`
|
|
||||||
.gantt-row-completed {
|
|
||||||
background-color: #f0fdf4;
|
|
||||||
}
|
|
||||||
.gantt-row-in-progress {
|
|
||||||
background-color: #eff6ff;
|
|
||||||
}
|
|
||||||
.gantt-row-paused {
|
|
||||||
background-color: #fefce8;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
12
apps/web/src/components/gantt/gantt.module.css
Normal file
12
apps/web/src/components/gantt/gantt.module.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/* Gantt Chart Status Row Styles */
|
||||||
|
.rowCompleted {
|
||||||
|
background-color: #f0fdf4; /* green-50 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowInProgress {
|
||||||
|
background-color: #eff6ff; /* blue-50 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowPaused {
|
||||||
|
background-color: #fefce8; /* yellow-50 */
|
||||||
|
}
|
||||||
23
apps/web/src/components/gantt/index.test.ts
Normal file
23
apps/web/src/components/gantt/index.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
GanttChart,
|
||||||
|
toGanttTask,
|
||||||
|
toGanttTasks,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
|
describe("Gantt module exports", () => {
|
||||||
|
it("should export GanttChart component", () => {
|
||||||
|
expect(GanttChart).toBeDefined();
|
||||||
|
expect(typeof GanttChart).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should export toGanttTask helper", () => {
|
||||||
|
expect(toGanttTask).toBeDefined();
|
||||||
|
expect(typeof toGanttTask).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should export toGanttTasks helper", () => {
|
||||||
|
expect(toGanttTasks).toBeDefined();
|
||||||
|
expect(typeof toGanttTasks).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Gantt Chart component exports
|
* Gantt Chart component exports
|
||||||
|
* @module gantt
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { GanttChart } from "./GanttChart";
|
export { GanttChart } from "./GanttChart";
|
||||||
export type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types";
|
export type {
|
||||||
|
GanttTask,
|
||||||
|
GanttChartProps,
|
||||||
|
TimelineRange,
|
||||||
|
GanttBarPosition,
|
||||||
|
} from "./types";
|
||||||
export { toGanttTask, toGanttTasks } from "./types";
|
export { toGanttTask, toGanttTasks } from "./types";
|
||||||
|
|||||||
@@ -116,6 +116,50 @@ describe("Gantt Types Helpers", () => {
|
|||||||
expect(ganttTask?.dependencies).toBeUndefined();
|
expect(ganttTask?.dependencies).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should extract isMilestone from metadata", () => {
|
||||||
|
const task = createTask({
|
||||||
|
metadata: {
|
||||||
|
startDate: "2026-02-01",
|
||||||
|
isMilestone: true,
|
||||||
|
},
|
||||||
|
dueDate: new Date("2026-02-15"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ganttTask = toGanttTask(task);
|
||||||
|
|
||||||
|
expect(ganttTask).not.toBeNull();
|
||||||
|
expect(ganttTask?.isMilestone).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should default isMilestone to false when not specified", () => {
|
||||||
|
const task = createTask({
|
||||||
|
metadata: {
|
||||||
|
startDate: "2026-02-01",
|
||||||
|
},
|
||||||
|
dueDate: new Date("2026-02-15"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ganttTask = toGanttTask(task);
|
||||||
|
|
||||||
|
expect(ganttTask).not.toBeNull();
|
||||||
|
expect(ganttTask?.isMilestone).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-boolean isMilestone in metadata", () => {
|
||||||
|
const task = createTask({
|
||||||
|
metadata: {
|
||||||
|
startDate: "2026-02-01",
|
||||||
|
isMilestone: "yes",
|
||||||
|
},
|
||||||
|
dueDate: new Date("2026-02-15"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ganttTask = toGanttTask(task);
|
||||||
|
|
||||||
|
expect(ganttTask).not.toBeNull();
|
||||||
|
expect(ganttTask?.isMilestone).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("should preserve all original task properties", () => {
|
it("should preserve all original task properties", () => {
|
||||||
const task = createTask({
|
const task = createTask({
|
||||||
id: "special-task",
|
id: "special-task",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface GanttTask extends Task {
|
|||||||
endDate: Date;
|
endDate: Date;
|
||||||
/** Optional array of task IDs that this task depends on */
|
/** Optional array of task IDs that this task depends on */
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
|
/** Whether this task is a milestone (zero-duration marker) */
|
||||||
|
isMilestone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,31 +58,50 @@ export interface GanttChartProps {
|
|||||||
showDependencies?: boolean;
|
showDependencies?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a value is a valid date string
|
||||||
|
*/
|
||||||
|
function isDateString(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && !isNaN(Date.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a value is an array of strings
|
||||||
|
*/
|
||||||
|
function isStringArray(value: unknown): value is string[] {
|
||||||
|
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to convert a base Task to GanttTask
|
* Helper to convert a base Task to GanttTask
|
||||||
* Uses createdAt as startDate if not in metadata, dueDate as endDate
|
* Uses createdAt as startDate if not in metadata, dueDate as endDate
|
||||||
*/
|
*/
|
||||||
export function toGanttTask(task: Task): GanttTask | null {
|
export function toGanttTask(task: Task): GanttTask | null {
|
||||||
// For Gantt chart, we need both start and end dates
|
// For Gantt chart, we need both start and end dates
|
||||||
const startDate =
|
const metadataStartDate = task.metadata?.startDate;
|
||||||
(task.metadata?.startDate as string | undefined)
|
const startDate = isDateString(metadataStartDate)
|
||||||
? new Date(task.metadata.startDate as string)
|
? new Date(metadataStartDate)
|
||||||
: task.createdAt;
|
: task.createdAt;
|
||||||
|
|
||||||
const endDate = task.dueDate || new Date();
|
const endDate = task.dueDate ?? new Date();
|
||||||
|
|
||||||
// Validate dates
|
// Validate dates
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract dependencies with type guard
|
||||||
|
const metadataDependencies = task.metadata?.dependencies;
|
||||||
|
const dependencies = isStringArray(metadataDependencies)
|
||||||
|
? metadataDependencies
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
dependencies: Array.isArray(task.metadata?.dependencies)
|
dependencies,
|
||||||
? (task.metadata.dependencies as string[])
|
isMilestone: task.metadata?.isMilestone === true,
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,3 +131,6 @@ export * from "./auth.types";
|
|||||||
|
|
||||||
// Export widget types
|
// Export widget types
|
||||||
export * from "./widget.types";
|
export * from "./widget.types";
|
||||||
|
|
||||||
|
// Export WebSocket types
|
||||||
|
export * from "./websocket.types";
|
||||||
|
|||||||
87
packages/shared/src/types/websocket.types.ts
Normal file
87
packages/shared/src/types/websocket.types.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket event types for real-time updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All supported WebSocket event names
|
||||||
|
*/
|
||||||
|
export enum WebSocketEvent {
|
||||||
|
// Task events
|
||||||
|
TASK_CREATED = 'task:created',
|
||||||
|
TASK_UPDATED = 'task:updated',
|
||||||
|
TASK_DELETED = 'task:deleted',
|
||||||
|
|
||||||
|
// Event events
|
||||||
|
EVENT_CREATED = 'event:created',
|
||||||
|
EVENT_UPDATED = 'event:updated',
|
||||||
|
EVENT_DELETED = 'event:deleted',
|
||||||
|
|
||||||
|
// Project events
|
||||||
|
PROJECT_CREATED = 'project:created',
|
||||||
|
PROJECT_UPDATED = 'project:updated',
|
||||||
|
PROJECT_DELETED = 'project:deleted',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base payload interface for all WebSocket events
|
||||||
|
*/
|
||||||
|
export interface BaseEventPayload {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task payload for WebSocket events
|
||||||
|
*/
|
||||||
|
export interface TaskEventPayload extends BaseEventPayload {
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
assigneeId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event (calendar) payload for WebSocket events
|
||||||
|
*/
|
||||||
|
export interface CalendarEventPayload extends BaseEventPayload {
|
||||||
|
title?: string;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project payload for WebSocket events
|
||||||
|
*/
|
||||||
|
export interface ProjectEventPayload extends BaseEventPayload {
|
||||||
|
name?: string;
|
||||||
|
status?: string;
|
||||||
|
color?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete payload - only includes the entity ID
|
||||||
|
*/
|
||||||
|
export interface DeleteEventPayload {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket event handler callbacks
|
||||||
|
*/
|
||||||
|
export interface WebSocketCallbacks {
|
||||||
|
onTaskCreated?: (task: TaskEventPayload) => void;
|
||||||
|
onTaskUpdated?: (task: TaskEventPayload) => void;
|
||||||
|
onTaskDeleted?: (payload: DeleteEventPayload) => void;
|
||||||
|
onEventCreated?: (event: CalendarEventPayload) => void;
|
||||||
|
onEventUpdated?: (event: CalendarEventPayload) => void;
|
||||||
|
onEventDeleted?: (payload: DeleteEventPayload) => void;
|
||||||
|
onProjectCreated?: (project: ProjectEventPayload) => void;
|
||||||
|
onProjectUpdated?: (project: ProjectEventPayload) => void;
|
||||||
|
onProjectDeleted?: (payload: DeleteEventPayload) => void;
|
||||||
|
}
|
||||||
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
@@ -147,6 +147,15 @@ importers:
|
|||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@19.2.4)
|
||||||
'@mosaic/shared':
|
'@mosaic/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
@@ -586,6 +595,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@emnapi/runtime@1.8.1':
|
'@emnapi/runtime@1.8.1':
|
||||||
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
|
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
|
||||||
|
|
||||||
@@ -4760,6 +4791,31 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/css-tokenizer@3.0.4': {}
|
'@csstools/css-tokenizer@3.0.4': {}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@emnapi/runtime@1.8.1':
|
'@emnapi/runtime@1.8.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user