Team/user CRMFS export + scoped chat
This commit is contained in:
195
Frontend/server/agent/crmAgent.ts
Normal file
195
Frontend/server/agent/crmAgent.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ChatRole, Prisma } from "@prisma/client";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { datasetRoot } from "../dataset/paths";
|
||||
import { ensureDataset } from "../dataset/exporter";
|
||||
|
||||
type ContactIndexRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string | null;
|
||||
lastMessageAt: string | null;
|
||||
nextEventAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AgentReply = {
|
||||
text: string;
|
||||
plan: string[];
|
||||
tools: string[];
|
||||
dbWrites?: Array<{ kind: string; detail: string }>;
|
||||
};
|
||||
|
||||
function normalize(s: string) {
|
||||
return s.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isToday(date: Date) {
|
||||
const now = new Date();
|
||||
return (
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
async function readContactIndex(): Promise<ContactIndexRow[]> {
|
||||
throw new Error("readContactIndex now requires dataset root");
|
||||
}
|
||||
|
||||
async function readContactIndexFrom(root: string): Promise<ContactIndexRow[]> {
|
||||
const p = path.join(root, "index", "contacts.json");
|
||||
const raw = await fs.readFile(p, "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function countJsonlLines(p: string): Promise<number> {
|
||||
const raw = await fs.readFile(p, "utf8");
|
||||
if (!raw.trim()) return 0;
|
||||
// cheap line count (JSONL is 1 item per line)
|
||||
return raw.trimEnd().split("\n").length;
|
||||
}
|
||||
|
||||
async function readJsonl(p: string): Promise<any[]> {
|
||||
const raw = await fs.readFile(p, "utf8");
|
||||
if (!raw.trim()) return [];
|
||||
return raw
|
||||
.trimEnd()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function formatContactLine(c: ContactIndexRow) {
|
||||
const company = c.company ? ` (${c.company})` : "";
|
||||
const lastAt = c.lastMessageAt ? new Date(c.lastMessageAt).toLocaleString("ru-RU") : "нет";
|
||||
return `- ${c.name}${company} · последнее: ${lastAt}`;
|
||||
}
|
||||
|
||||
export async function runCrmAgent(userText: string): Promise<AgentReply> {
|
||||
throw new Error("runCrmAgent now requires auth context");
|
||||
}
|
||||
|
||||
export async function runCrmAgentFor(
|
||||
input: { teamId: string; userId: string; userText: string },
|
||||
): Promise<AgentReply> {
|
||||
await ensureDataset({ teamId: input.teamId, userId: input.userId });
|
||||
const q = normalize(input.userText);
|
||||
const root = datasetRoot({ teamId: input.teamId, userId: input.userId });
|
||||
const contacts = await readContactIndexFrom(root);
|
||||
|
||||
// "10 лучших клиентов"
|
||||
if (q.includes("10 лучших") || (q.includes("топ") && q.includes("клиент"))) {
|
||||
const ranked = await Promise.all(
|
||||
contacts.map(async (c) => {
|
||||
const msgPath = path.join(root, "messages", `${c.id}.jsonl`);
|
||||
const evPath = path.join(root, "events", `${c.id}.jsonl`);
|
||||
const msgCount = await countJsonlLines(msgPath).catch(() => 0);
|
||||
const ev = await readJsonl(evPath).catch(() => []);
|
||||
const todayEvCount = ev.filter((e) => (e?.startsAt ? isToday(new Date(e.startsAt)) : false)).length;
|
||||
const score = msgCount * 2 + todayEvCount * 3;
|
||||
return { c, score };
|
||||
}),
|
||||
);
|
||||
|
||||
ranked.sort((a, b) => b.score - a.score);
|
||||
const top = ranked.slice(0, 10).map((x) => x.c);
|
||||
|
||||
return {
|
||||
plan: [
|
||||
"Загрузить индекс контактов из файлового датасета",
|
||||
"Посчитать активность по JSONL (сообщения/события сегодня)",
|
||||
"Отсортировать и показать топ",
|
||||
],
|
||||
tools: ["read index/contacts.json", "read messages/{contactId}.jsonl", "read events/{contactId}.jsonl"],
|
||||
text:
|
||||
`Топ-10 по активности (сообщения + события):\n` +
|
||||
top.map(formatContactLine).join("\n") +
|
||||
`\n\nЕсли хочешь, скажи критерий "лучший" (выручка/стадия/вероятность/давность) и я пересчитаю.`,
|
||||
};
|
||||
}
|
||||
|
||||
// "чем заняться сегодня"
|
||||
if (q.includes("чем") && (q.includes("сегодня") || q.includes("заняться"))) {
|
||||
const todayEvents: Array<{ who: string; title: string; at: Date; note?: string | null }> = [];
|
||||
|
||||
for (const c of contacts) {
|
||||
const evPath = path.join(root, "events", `${c.id}.jsonl`);
|
||||
const ev = await readJsonl(evPath).catch(() => []);
|
||||
for (const e of ev) {
|
||||
if (!e?.startsAt) continue;
|
||||
const at = new Date(e.startsAt);
|
||||
if (!isToday(at)) continue;
|
||||
todayEvents.push({ who: c.name, title: e.title ?? "Event", at, note: e.note ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
todayEvents.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
|
||||
const followups = [...contacts]
|
||||
.map((c) => ({ c, last: c.lastMessageAt ? new Date(c.lastMessageAt).getTime() : 0 }))
|
||||
.sort((a, b) => a.last - b.last)
|
||||
.slice(0, 3)
|
||||
.map((x) => x.c);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (todayEvents.length > 0) {
|
||||
lines.push("Сегодня по календарю:");
|
||||
for (const e of todayEvents) {
|
||||
const hhmm = e.at.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
lines.push(`- ${hhmm} · ${e.title} · ${e.who}${e.note ? ` · ${e.note}` : ""}`);
|
||||
}
|
||||
} else {
|
||||
lines.push("Сегодня нет запланированных событий в календаре.");
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Фокус дня (если нужно добить прогресс):");
|
||||
for (const c of followups) {
|
||||
lines.push(`- Написать follow-up: ${c.name}${c.company ? ` (${c.company})` : ""}`);
|
||||
}
|
||||
|
||||
return {
|
||||
plan: [
|
||||
"Прочитать события на сегодня из файлового датасета",
|
||||
"Найти контакты без свежего касания (по lastMessageAt)",
|
||||
"Сформировать короткий список действий",
|
||||
],
|
||||
tools: ["read index/contacts.json", "read events/{contactId}.jsonl"],
|
||||
text: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
// Default: keep it simple, ask for intent + show what the agent can do.
|
||||
return {
|
||||
plan: ["Уточнить цель", "Выбрать данные для анализа", "Предложить план действий и, если нужно, изменения в CRM"],
|
||||
tools: ["read index/contacts.json (по необходимости)", "search messages/events (по необходимости)"],
|
||||
text:
|
||||
"Ок. Скажи, что нужно сделать.\n" +
|
||||
"Примеры:\n" +
|
||||
"- \"покажи 10 лучших клиентов\"\n" +
|
||||
"- \"чем мне сегодня заняться\"\n" +
|
||||
"- \"составь план касаний на неделю\"\n",
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistChatMessage(input: {
|
||||
role: ChatRole;
|
||||
text: string;
|
||||
plan?: string[];
|
||||
tools?: string[];
|
||||
teamId: string;
|
||||
conversationId: string;
|
||||
authorUserId?: string | null;
|
||||
}) {
|
||||
const data: Prisma.ChatMessageCreateInput = {
|
||||
team: { connect: { id: input.teamId } },
|
||||
conversation: { connect: { id: input.conversationId } },
|
||||
authorUser: input.authorUserId ? { connect: { id: input.authorUserId } } : undefined,
|
||||
role: input.role,
|
||||
text: input.text,
|
||||
planJson: input.plan || input.tools ? ({ steps: input.plan ?? [], tools: input.tools ?? [] } as any) : undefined,
|
||||
};
|
||||
return prisma.chatMessage.create({ data });
|
||||
}
|
||||
22
Frontend/server/api/chat.get.ts
Normal file
22
Frontend/server/api/chat.get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { getAuthContext } from "../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const items = await prisma.chatMessage.findMany({
|
||||
where: { teamId: auth.teamId, conversationId: auth.conversationId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system",
|
||||
text: m.text,
|
||||
plan: (m.planJson as any)?.steps ?? null,
|
||||
tools: (m.planJson as any)?.tools ?? null,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
};
|
||||
});
|
||||
33
Frontend/server/api/chat.post.ts
Normal file
33
Frontend/server/api/chat.post.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readBody } from "h3";
|
||||
import { persistChatMessage, runCrmAgentFor } from "../agent/crmAgent";
|
||||
import { getAuthContext } from "../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ text?: string }>(event);
|
||||
const text = (body?.text ?? "").trim();
|
||||
if (!text) {
|
||||
throw createError({ statusCode: 400, statusMessage: "text is required" });
|
||||
}
|
||||
|
||||
const auth = await getAuthContext(event);
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: auth.userId,
|
||||
role: "USER",
|
||||
text,
|
||||
});
|
||||
|
||||
const reply = await runCrmAgentFor({ teamId: auth.teamId, userId: auth.userId, userText: text });
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: null,
|
||||
role: "ASSISTANT",
|
||||
text: reply.text,
|
||||
plan: reply.plan,
|
||||
tools: reply.tools,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
25
Frontend/server/api/chat/log.post.ts
Normal file
25
Frontend/server/api/chat/log.post.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { readBody } from "h3";
|
||||
import { persistChatMessage } from "../../agent/crmAgent";
|
||||
import { getAuthContext } from "../../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ text?: string }>(event);
|
||||
const text = (body?.text ?? "").trim();
|
||||
if (!text) {
|
||||
throw createError({ statusCode: 400, statusMessage: "text is required" });
|
||||
}
|
||||
|
||||
const auth = await getAuthContext(event);
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: null,
|
||||
role: "ASSISTANT",
|
||||
text,
|
||||
plan: [],
|
||||
tools: [],
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
8
Frontend/server/api/dataset/export.post.ts
Normal file
8
Frontend/server/api/dataset/export.post.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { exportDatasetFromPrismaFor } from "../../dataset/exporter";
|
||||
import { getAuthContext } from "../../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
await exportDatasetFromPrismaFor({ teamId: auth.teamId, userId: auth.userId });
|
||||
return { ok: true };
|
||||
});
|
||||
142
Frontend/server/dataset/exporter.ts
Normal file
142
Frontend/server/dataset/exporter.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { datasetRoot } from "./paths";
|
||||
|
||||
type ExportMeta = {
|
||||
exportedAt: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
async function ensureDir(p: string) {
|
||||
await fs.mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeJson(p: string, value: unknown) {
|
||||
await fs.writeFile(p, JSON.stringify(value, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function jsonlLine(value: unknown) {
|
||||
return JSON.stringify(value) + "\n";
|
||||
}
|
||||
|
||||
export async function exportDatasetFromPrisma() {
|
||||
throw new Error("exportDatasetFromPrisma now requires { teamId, userId }");
|
||||
}
|
||||
|
||||
export async function exportDatasetFromPrismaFor(input: { teamId: string; userId: string }) {
|
||||
const root = datasetRoot(input);
|
||||
const tmp = root + ".tmp";
|
||||
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
await ensureDir(tmp);
|
||||
|
||||
const contactsDir = path.join(tmp, "contacts");
|
||||
const notesDir = path.join(tmp, "notes");
|
||||
const messagesDir = path.join(tmp, "messages");
|
||||
const eventsDir = path.join(tmp, "events");
|
||||
const indexDir = path.join(tmp, "index");
|
||||
await Promise.all([
|
||||
ensureDir(contactsDir),
|
||||
ensureDir(notesDir),
|
||||
ensureDir(messagesDir),
|
||||
ensureDir(eventsDir),
|
||||
ensureDir(indexDir),
|
||||
]);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: { teamId: input.teamId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: {
|
||||
note: { select: { content: true, updatedAt: true } },
|
||||
messages: {
|
||||
select: { direction: true, channel: true, content: true, occurredAt: true },
|
||||
orderBy: { occurredAt: "asc" },
|
||||
},
|
||||
events: {
|
||||
select: { title: true, startsAt: true, endsAt: true, status: true, note: true },
|
||||
orderBy: { startsAt: "asc" },
|
||||
},
|
||||
},
|
||||
take: 5000,
|
||||
});
|
||||
|
||||
const contactIndex = [];
|
||||
|
||||
for (const c of contacts) {
|
||||
const contactFile = path.join(contactsDir, `${c.id}.json`);
|
||||
await writeJson(contactFile, {
|
||||
id: c.id,
|
||||
teamId: c.teamId,
|
||||
name: c.name,
|
||||
company: c.company ?? null,
|
||||
email: c.email ?? null,
|
||||
phone: c.phone ?? null,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
});
|
||||
|
||||
const noteFile = path.join(notesDir, `${c.id}.md`);
|
||||
await fs.writeFile(
|
||||
noteFile,
|
||||
(c.note?.content?.trim() ? c.note.content.trim() : "") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const msgFile = path.join(messagesDir, `${c.id}.jsonl`);
|
||||
const msgLines = c.messages.map((m) =>
|
||||
jsonlLine({
|
||||
direction: m.direction,
|
||||
channel: m.channel,
|
||||
occurredAt: m.occurredAt,
|
||||
content: m.content,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(msgFile, msgLines.join(""), "utf8");
|
||||
|
||||
const evFile = path.join(eventsDir, `${c.id}.jsonl`);
|
||||
const evLines = c.events.map((e) =>
|
||||
jsonlLine({
|
||||
title: e.title,
|
||||
startsAt: e.startsAt,
|
||||
endsAt: e.endsAt,
|
||||
status: e.status ?? null,
|
||||
note: e.note ?? null,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(evFile, evLines.join(""), "utf8");
|
||||
|
||||
const lastMessageAt = c.messages.length ? c.messages[c.messages.length - 1].occurredAt : null;
|
||||
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
|
||||
|
||||
contactIndex.push({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
company: c.company ?? null,
|
||||
lastMessageAt,
|
||||
nextEventAt,
|
||||
updatedAt: c.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
await writeJson(path.join(indexDir, "contacts.json"), contactIndex);
|
||||
|
||||
const meta: ExportMeta = { exportedAt: new Date().toISOString(), version: 1 };
|
||||
await writeJson(path.join(tmp, "meta.json"), meta);
|
||||
|
||||
await ensureDir(path.dirname(root));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rename(tmp, root);
|
||||
}
|
||||
|
||||
export async function ensureDataset(input: { teamId: string; userId: string }) {
|
||||
const root = datasetRoot(input);
|
||||
try {
|
||||
const metaPath = path.join(root, "meta.json");
|
||||
await fs.access(metaPath);
|
||||
return;
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
await exportDatasetFromPrismaFor(input);
|
||||
}
|
||||
6
Frontend/server/dataset/paths.ts
Normal file
6
Frontend/server/dataset/paths.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function datasetRoot(input: { teamId: string; userId: string }) {
|
||||
// Keep it outside Frontend so it can be easily ignored and shared.
|
||||
return path.resolve(process.cwd(), "..", ".data", "crmfs", "teams", input.teamId, "users", input.userId);
|
||||
}
|
||||
57
Frontend/server/utils/auth.ts
Normal file
57
Frontend/server/utils/auth.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { prisma } from "./prisma";
|
||||
import type { H3Event } from "h3";
|
||||
|
||||
export type AuthContext = {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
// Minimal temporary auth: pick from headers or auto-provision a default team/user.
|
||||
export async function getAuthContext(event: H3Event): Promise<AuthContext> {
|
||||
const hdrTeam = getHeader(event, "x-team-id")?.trim();
|
||||
const hdrUser = getHeader(event, "x-user-id")?.trim();
|
||||
const hdrConv = getHeader(event, "x-conversation-id")?.trim();
|
||||
|
||||
// Ensure default team/user exist.
|
||||
const user =
|
||||
(hdrUser ? await prisma.user.findUnique({ where: { id: hdrUser } }) : null) ??
|
||||
(await prisma.user.upsert({
|
||||
where: { id: "demo-user" },
|
||||
update: { email: "demo@clientsflow.local", name: "Demo User" },
|
||||
create: { id: "demo-user", email: "demo@clientsflow.local", name: "Demo User" },
|
||||
}));
|
||||
|
||||
const team =
|
||||
(hdrTeam
|
||||
? await prisma.team.findUnique({ where: { id: hdrTeam } })
|
||||
: null) ??
|
||||
(await prisma.team.upsert({
|
||||
where: { id: "demo-team" },
|
||||
update: { name: "Demo Team" },
|
||||
create: { id: "demo-team", name: "Demo Team" },
|
||||
}));
|
||||
|
||||
await prisma.teamMember.upsert({
|
||||
where: { teamId_userId: { teamId: team.id, userId: user.id } },
|
||||
update: {},
|
||||
create: { teamId: team.id, userId: user.id, role: "OWNER" },
|
||||
});
|
||||
|
||||
const conversation =
|
||||
(hdrConv
|
||||
? await prisma.chatConversation.findUnique({ where: { id: hdrConv } })
|
||||
: null) ??
|
||||
(await prisma.chatConversation.upsert({
|
||||
where: { id: `pilot-${team.id}` },
|
||||
update: {},
|
||||
create: {
|
||||
id: `pilot-${team.id}`,
|
||||
teamId: team.id,
|
||||
createdByUserId: user.id,
|
||||
title: "Pilot",
|
||||
},
|
||||
}));
|
||||
|
||||
return { teamId: team.id, userId: user.id, conversationId: conversation.id };
|
||||
}
|
||||
17
Frontend/server/utils/prisma.ts
Normal file
17
Frontend/server/utils/prisma.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalThis.__prisma ??
|
||||
new PrismaClient({
|
||||
log: ["error", "warn"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__prisma = prisma;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user