Files
clientsflow/frontend/server/agent/crmAgent.ts
Ruslan Bakiev 6291797bb6 chore: upgrade Prisma 7, LangChain 1.x, Tailwind 4.2, Vue 3.5.29 and other deps
- Prisma 6 → 7: new prisma-client generator, prisma.config.ts, PrismaPg adapter, updated all imports
- LangChain 0.x → 1.x: @langchain/core, langgraph, openai
- Tailwind 4.1 → 4.2.1, daisyUI 5.5.19, Vue 3.5.29, ai 6.0.99, zod 4.3.6
- Fix MessageDirection bug in crm-updates.ts (OUTBOUND → OUT)
- Add server/generated to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:27:26 +07:00

295 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from "node:fs/promises";
import path from "node:path";
import type { ChatRole, Prisma } from "../generated/prisma/client";
import { prisma } from "../utils/prisma";
import { datasetRoot } from "../dataset/paths";
import { ensureDataset } from "../dataset/exporter";
import { runLangGraphCrmAgentFor } from "./langgraphCrmAgent";
import type { ChangeSet } from "../utils/changeSet";
type ContactIndexRow = {
id: string;
name: string;
lastMessageAt: string | null;
nextEventAt: string | null;
updatedAt: string;
};
export type AgentReply = {
text: string;
plan: string[];
tools: string[];
thinking?: string[];
toolRuns?: Array<{
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
}>;
dbWrites?: Array<{ kind: string; detail: string }>;
};
export type AgentTraceEvent = {
text: string;
toolRun?: {
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
};
};
export type PilotContextPayload = {
scopes: Array<"summary" | "deal" | "message" | "calendar">;
summary?: {
contactId: string;
name: string;
};
deal?: {
dealId: string;
title: string;
contact: string;
};
message?: {
contactId?: string;
contact?: string;
intent: "add_message_or_reminder";
};
calendar?: {
view: "day" | "week" | "month" | "year" | "agenda";
period: string;
selectedDateKey: string;
focusedEventId?: string;
eventIds: 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 lastAt = c.lastMessageAt ? new Date(c.lastMessageAt).toLocaleString("ru-RU") : "нет";
return `- ${c.name} · последнее: ${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;
contextPayload?: PilotContextPayload | null;
requestId?: string;
conversationId?: string;
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;
},
): Promise<AgentReply> {
const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase();
const llmApiKey =
process.env.OPENROUTER_API_KEY ||
process.env.LLM_API_KEY ||
process.env.OPENAI_API_KEY ||
process.env.DASHSCOPE_API_KEY ||
process.env.QWEN_API_KEY;
const hasGigachat = Boolean((process.env.GIGACHAT_AUTH_KEY ?? "").trim() && (process.env.GIGACHAT_SCOPE ?? "").trim());
if (mode !== "rule") {
return runLangGraphCrmAgentFor(input);
}
if (!llmApiKey && !hasGigachat) {
throw new Error("LLM API key is not configured. Set OPENROUTER_API_KEY or GIGACHAT_AUTH_KEY/GIGACHAT_SCOPE.");
}
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"],
toolRuns: [
{
name: "dataset:index_contacts",
status: "ok",
input: "index/contacts.json",
output: "Loaded contacts index",
at: new Date().toISOString(),
},
],
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}`);
}
return {
plan: [
"Прочитать события на сегодня из файлового датасета",
"Найти контакты без свежего касания (по lastMessageAt)",
"Сформировать короткий список действий",
],
tools: ["read index/contacts.json", "read events/{contactId}.jsonl"],
toolRuns: [
{
name: "dataset:query_events",
status: "ok",
input: "events/*.jsonl (today)",
output: `Found ${todayEvents.length} events`,
at: new Date().toISOString(),
},
],
text: lines.join("\n"),
};
}
throw new Error(
"Rule mode supports only structured built-in queries. Use a supported query or switch to langgraph mode with a configured LLM API key.",
);
}
export async function persistAiMessage(input: {
role: ChatRole;
text: string;
plan?: string[];
tools?: string[];
thinking?: string[];
toolRuns?: Array<{
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
}>;
changeSet?: ChangeSet | null;
requestId?: string;
eventType?: "user" | "trace" | "assistant" | "note";
phase?: "pending" | "running" | "final" | "error";
transient?: boolean;
messageKind?: "change_set_summary";
teamId: string;
conversationId: string;
authorUserId?: string | null;
}) {
const hasStoredPayload = Boolean(input.changeSet || input.messageKind);
const data: Prisma.AiMessageCreateInput = {
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: hasStoredPayload
? ({
messageKind: input.messageKind ?? null,
changeSet: input.changeSet ?? null,
} as any)
: undefined,
};
return prisma.aiMessage.create({ data });
}