371 lines
9.4 KiB
TypeScript
371 lines
9.4 KiB
TypeScript
// src/services/chat.service.ts
|
|
// Copyright (C) 2026 DTP Technologies, LLC
|
|
// All Rights Reserved
|
|
|
|
import { Types } from "mongoose";
|
|
import {
|
|
ChatSession,
|
|
ChatSessionType,
|
|
ChatSessionMode,
|
|
} from "../models/chat-session.js";
|
|
import {
|
|
ChatHistory,
|
|
IChatHistory as IChatHistoryModel,
|
|
ChatHistoryStatus,
|
|
} from "../models/chat-history.js";
|
|
import { DtpService } from "../lib/service.js";
|
|
|
|
export interface IChatSessionListItem {
|
|
_id: string;
|
|
name: string;
|
|
lastMessageAt: Date | null;
|
|
turnCount: number;
|
|
toolCallCount: number;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
createdAt: Date;
|
|
mode: ChatSessionMode;
|
|
}
|
|
|
|
export interface IChatSessionDetail extends IChatSessionListItem {
|
|
user: string;
|
|
project?: string;
|
|
type: ChatSessionType;
|
|
pins: Array<{ _id?: string; content: string }>;
|
|
}
|
|
|
|
export interface IChatHistoryEntry {
|
|
_id: string;
|
|
sessionId: string;
|
|
prompt: string;
|
|
response: {
|
|
thinking?: string;
|
|
message?: string;
|
|
};
|
|
status: ChatHistoryStatus;
|
|
toolCalls: Array<{
|
|
tool: {
|
|
name: string;
|
|
callId: string;
|
|
parameters: Array<{ name: string; value: string }>;
|
|
};
|
|
response?: string;
|
|
fileOperation?: {
|
|
type: "read" | "write" | "edit" | "shell";
|
|
path?: string;
|
|
diff?: string;
|
|
linesAdded?: number;
|
|
linesRemoved?: number;
|
|
isBinary?: boolean;
|
|
};
|
|
subagentStats?: {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
toolCallCount: number;
|
|
};
|
|
}>;
|
|
fileOperations: Array<{
|
|
type: "read" | "write" | "edit" | "shell";
|
|
path?: string;
|
|
diff?: string;
|
|
linesAdded?: number;
|
|
linesRemoved?: number;
|
|
isBinary?: boolean;
|
|
}>;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
createdAt: Date;
|
|
mode: ChatSessionMode;
|
|
isSubagent: boolean;
|
|
error?: {
|
|
message: string;
|
|
stack?: string;
|
|
timestamp: Date;
|
|
};
|
|
}
|
|
|
|
export class ChatService extends DtpService {
|
|
get name(): string {
|
|
return "ChatService";
|
|
}
|
|
get slug(): string {
|
|
return "chat";
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.log.info("service started");
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.log.info("service stopped");
|
|
}
|
|
|
|
// ==================== Session Management ====================
|
|
|
|
async listByProject(
|
|
projectId: string,
|
|
userId: string,
|
|
): Promise<IChatSessionListItem[]> {
|
|
const sessions = await ChatSession.find({
|
|
user: userId,
|
|
project: projectId,
|
|
})
|
|
.sort({ lastMessageAt: -1, createdAt: -1 })
|
|
.lean();
|
|
|
|
return sessions.map((s) => ({
|
|
_id: s._id.toString(),
|
|
name: s.name,
|
|
lastMessageAt: s.lastMessageAt ?? null,
|
|
turnCount: s.stats.turnCount,
|
|
toolCallCount: s.stats.toolCallCount,
|
|
inputTokens: s.stats.inputTokens,
|
|
outputTokens: s.stats.outputTokens,
|
|
createdAt: s.createdAt,
|
|
mode: s.mode,
|
|
}));
|
|
}
|
|
|
|
async listAll(userId: string): Promise<IChatSessionListItem[]> {
|
|
const sessions = await ChatSession.find({
|
|
user: userId,
|
|
})
|
|
.sort({ lastMessageAt: -1, createdAt: -1 })
|
|
.limit(50)
|
|
.lean();
|
|
|
|
return sessions.map((s) => ({
|
|
_id: s._id.toString(),
|
|
name: s.name,
|
|
lastMessageAt: s.lastMessageAt ?? null,
|
|
turnCount: s.stats.turnCount,
|
|
toolCallCount: s.stats.toolCallCount,
|
|
inputTokens: s.stats.inputTokens,
|
|
outputTokens: s.stats.outputTokens,
|
|
createdAt: s.createdAt,
|
|
mode: s.mode,
|
|
}));
|
|
}
|
|
|
|
async create(
|
|
userId: string,
|
|
projectId?: string,
|
|
name?: string,
|
|
): Promise<IChatSessionListItem> {
|
|
const session = new ChatSession({
|
|
user: userId,
|
|
project: projectId,
|
|
name: name ?? "New Session",
|
|
type: ChatSessionType.Desktop,
|
|
mode: ChatSessionMode.Build,
|
|
});
|
|
await session.save();
|
|
|
|
this.log.info("ChatSession created", {
|
|
sessionId: session._id,
|
|
userId,
|
|
projectId,
|
|
});
|
|
|
|
return {
|
|
_id: session._id.toString(),
|
|
name: session.name,
|
|
lastMessageAt: null,
|
|
turnCount: 0,
|
|
toolCallCount: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
createdAt: session.createdAt,
|
|
mode: session.mode,
|
|
};
|
|
}
|
|
|
|
async findById(sessionId: string): Promise<IChatSessionDetail | null> {
|
|
const session = await ChatSession.findById(sessionId).lean();
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
const sessionWithProject = await ChatSession.findById(sessionId)
|
|
.populate("project", "name")
|
|
.lean() as any;
|
|
const projectName = (sessionWithProject?.project as any)?.name;
|
|
|
|
const userId =
|
|
session.user instanceof Types.ObjectId
|
|
? session.user.toString()
|
|
: String(session.user);
|
|
|
|
return {
|
|
_id: session._id.toString(),
|
|
name: session.name,
|
|
lastMessageAt: session.lastMessageAt ?? null,
|
|
turnCount: session.stats.turnCount,
|
|
toolCallCount: session.stats.toolCallCount,
|
|
inputTokens: session.stats.inputTokens,
|
|
outputTokens: session.stats.outputTokens,
|
|
createdAt: session.createdAt,
|
|
mode: session.mode,
|
|
user: userId,
|
|
project: projectName,
|
|
type: session.type,
|
|
pins: session.pins.map((p) => ({
|
|
_id: p._id?.toString(),
|
|
content: p.content,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async updateName(sessionId: string, name: string): Promise<void> {
|
|
await ChatSession.findByIdAndUpdate(sessionId, { name });
|
|
this.log.info("ChatSession name updated", { sessionId, name });
|
|
}
|
|
|
|
async updateMode(sessionId: string, mode: ChatSessionMode): Promise<void> {
|
|
await ChatSession.findByIdAndUpdate(sessionId, { mode });
|
|
this.log.info("ChatSession mode updated", { sessionId, mode });
|
|
}
|
|
|
|
async delete(sessionId: string, userId: string): Promise<void> {
|
|
const result = await ChatSession.deleteOne({
|
|
_id: sessionId,
|
|
user: userId,
|
|
});
|
|
if (result.deletedCount === 0) {
|
|
throw new Error("ChatSession not found or not owned by user");
|
|
}
|
|
this.log.info("ChatSession deleted", { sessionId, userId });
|
|
}
|
|
|
|
// ==================== History Management ====================
|
|
|
|
async getHistory(sessionId: string): Promise<IChatHistoryEntry[]> {
|
|
const history = await ChatHistory.find({ session: sessionId })
|
|
.sort({ createdAt: 1 })
|
|
.lean();
|
|
|
|
return history.map((h) => ({
|
|
_id: h._id.toString(),
|
|
sessionId: h.session.toString(),
|
|
prompt: h.prompt,
|
|
response: {
|
|
thinking: h.response?.thinking,
|
|
message: h.response?.message,
|
|
},
|
|
status: h.status,
|
|
toolCalls: h.toolCalls.map((tc) => ({
|
|
tool: {
|
|
name: tc.tool.name,
|
|
callId: tc.tool.callId,
|
|
parameters: tc.tool.parameters.map((p) => ({
|
|
name: p.name,
|
|
value: p.value,
|
|
})),
|
|
},
|
|
response: tc.response,
|
|
fileOperation: tc.fileOperation,
|
|
subagentStats: tc.subagentStats,
|
|
})),
|
|
fileOperations: h.fileOperations,
|
|
inputTokens: h.inputTokens,
|
|
outputTokens: h.outputTokens,
|
|
createdAt: h.createdAt,
|
|
mode: h.mode,
|
|
isSubagent: h.isSubagent ?? false,
|
|
subagentHistory: h.subagentHistory || [],
|
|
error: h.error,
|
|
}));
|
|
}
|
|
|
|
async createHistoryEntry(
|
|
sessionId: string,
|
|
userId: string,
|
|
prompt: string,
|
|
mode: ChatSessionMode,
|
|
): Promise<IChatHistoryEntry> {
|
|
const entry = new ChatHistory({
|
|
session: sessionId,
|
|
user: userId,
|
|
prompt,
|
|
mode,
|
|
status: ChatHistoryStatus.Processing,
|
|
});
|
|
await entry.save();
|
|
|
|
// Update session stats
|
|
await ChatSession.findByIdAndUpdate(sessionId, {
|
|
$inc: {
|
|
"stats.turnCount": 1,
|
|
"stats.inputTokens": entry.inputTokens,
|
|
"stats.outputTokens": entry.outputTokens,
|
|
},
|
|
lastMessageAt: new Date(),
|
|
});
|
|
|
|
this.log.info("ChatHistory entry created", {
|
|
historyId: entry._id,
|
|
sessionId,
|
|
userId,
|
|
});
|
|
|
|
return {
|
|
_id: entry._id.toString(),
|
|
sessionId,
|
|
prompt: entry.prompt,
|
|
response: {
|
|
thinking: entry.response?.thinking,
|
|
message: entry.response?.message,
|
|
},
|
|
status: entry.status,
|
|
toolCalls: [],
|
|
fileOperations: [],
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
createdAt: entry.createdAt,
|
|
mode: entry.mode,
|
|
isSubagent: entry.isSubagent ?? false,
|
|
};
|
|
}
|
|
|
|
async updateHistoryEntry(
|
|
historyId: string,
|
|
updates: Partial<{
|
|
response: { thinking?: string; message?: string };
|
|
status: ChatHistoryStatus;
|
|
toolCalls: IChatHistoryModel["toolCalls"];
|
|
fileOperations: IChatHistoryModel["fileOperations"];
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
error: { message: string; stack?: string; timestamp: Date };
|
|
}>,
|
|
): Promise<void> {
|
|
const updateData: any = {};
|
|
if (updates.response !== undefined) {
|
|
updateData.response = updates.response;
|
|
}
|
|
if (updates.status !== undefined) {
|
|
updateData.status = updates.status;
|
|
}
|
|
if (updates.toolCalls !== undefined) {
|
|
updateData.toolCalls = updates.toolCalls;
|
|
}
|
|
if (updates.fileOperations !== undefined) {
|
|
updateData.fileOperations = updates.fileOperations;
|
|
}
|
|
if (updates.inputTokens !== undefined) {
|
|
updateData.inputTokens = updates.inputTokens;
|
|
}
|
|
if (updates.outputTokens !== undefined) {
|
|
updateData.outputTokens = updates.outputTokens;
|
|
}
|
|
if (updates.error !== undefined) {
|
|
updateData.error = updates.error;
|
|
}
|
|
|
|
await ChatHistory.findByIdAndUpdate(historyId, updateData);
|
|
this.log.info("ChatHistory entry updated", { historyId });
|
|
}
|
|
}
|
|
|
|
export default new ChatService();
|