chore: rename service folders to lowercase
This commit is contained in:
270
frontend/server/agent/crmAgent.ts
Normal file
270
frontend/server/agent/crmAgent.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
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";
|
||||
import { runLangGraphCrmAgentFor } from "./langgraphCrmAgent";
|
||||
import type { ChangeSet } from "../utils/changeSet";
|
||||
|
||||
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[];
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
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}${c.company ? ` (${c.company})` : ""}`);
|
||||
}
|
||||
|
||||
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 persistChatMessage(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.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: hasStoredPayload
|
||||
? ({
|
||||
messageKind: input.messageKind ?? null,
|
||||
changeSet: input.changeSet ?? null,
|
||||
} as any)
|
||||
: undefined,
|
||||
};
|
||||
return prisma.chatMessage.create({ data });
|
||||
}
|
||||
1120
frontend/server/agent/langgraphCrmAgent.ts
Normal file
1120
frontend/server/agent/langgraphCrmAgent.ts
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/server/api/graphql.post.ts
Normal file
39
frontend/server/api/graphql.post.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { readBody } from "h3";
|
||||
import { graphql } from "graphql";
|
||||
import { getAuthContext } from "../utils/auth";
|
||||
import { crmGraphqlRoot, crmGraphqlSchema } from "../graphql/schema";
|
||||
|
||||
type GraphqlBody = {
|
||||
query?: string;
|
||||
operationName?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<GraphqlBody>(event);
|
||||
|
||||
if (!body?.query || !body.query.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: "GraphQL query is required" });
|
||||
}
|
||||
|
||||
let auth = null;
|
||||
try {
|
||||
auth = await getAuthContext(event);
|
||||
} catch {
|
||||
auth = null;
|
||||
}
|
||||
|
||||
const result = await graphql({
|
||||
schema: crmGraphqlSchema,
|
||||
source: body.query,
|
||||
rootValue: crmGraphqlRoot,
|
||||
contextValue: { auth, event },
|
||||
variableValues: body.variables,
|
||||
operationName: body.operationName,
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data ?? null,
|
||||
errors: result.errors?.map((error) => ({ message: error.message })) ?? undefined,
|
||||
};
|
||||
});
|
||||
60
frontend/server/api/omni/delivery/enqueue.post.ts
Normal file
60
frontend/server/api/omni/delivery/enqueue.post.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { readBody } from "h3";
|
||||
import { getAuthContext } from "../../../utils/auth";
|
||||
import { prisma } from "../../../utils/prisma";
|
||||
import { enqueueOutboundDelivery } from "../../../queues/outboundDelivery";
|
||||
|
||||
type EnqueueBody = {
|
||||
omniMessageId?: string;
|
||||
endpoint?: string;
|
||||
method?: "POST" | "PUT" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
payload?: unknown;
|
||||
provider?: string;
|
||||
channel?: string;
|
||||
attempts?: number;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const body = await readBody<EnqueueBody>(event);
|
||||
|
||||
const omniMessageId = String(body?.omniMessageId ?? "").trim();
|
||||
const endpoint = String(body?.endpoint ?? "").trim();
|
||||
if (!omniMessageId) {
|
||||
throw createError({ statusCode: 400, statusMessage: "omniMessageId is required" });
|
||||
}
|
||||
if (!endpoint) {
|
||||
throw createError({ statusCode: 400, statusMessage: "endpoint is required" });
|
||||
}
|
||||
|
||||
const msg = await prisma.omniMessage.findFirst({
|
||||
where: { id: omniMessageId, teamId: auth.teamId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!msg) {
|
||||
throw createError({ statusCode: 404, statusMessage: "omni message not found" });
|
||||
}
|
||||
|
||||
const attempts = Math.max(1, Math.min(Number(body?.attempts ?? 12), 50));
|
||||
const job = await enqueueOutboundDelivery(
|
||||
{
|
||||
omniMessageId,
|
||||
endpoint,
|
||||
method: body?.method ?? "POST",
|
||||
headers: body?.headers ?? {},
|
||||
payload: body?.payload ?? {},
|
||||
provider: body?.provider ?? undefined,
|
||||
channel: body?.channel ?? undefined,
|
||||
},
|
||||
{
|
||||
attempts,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
queue: "omni-outbound",
|
||||
jobId: job.id,
|
||||
omniMessageId,
|
||||
};
|
||||
});
|
||||
32
frontend/server/api/omni/telegram/send.post.ts
Normal file
32
frontend/server/api/omni/telegram/send.post.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { readBody } from "h3";
|
||||
import { getAuthContext } from "../../../utils/auth";
|
||||
import { prisma } from "../../../utils/prisma";
|
||||
import { enqueueTelegramSend } from "../../../queues/telegramSend";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const body = await readBody<{ omniMessageId?: string; attempts?: number }>(event);
|
||||
|
||||
const omniMessageId = String(body?.omniMessageId ?? "").trim();
|
||||
if (!omniMessageId) {
|
||||
throw createError({ statusCode: 400, statusMessage: "omniMessageId is required" });
|
||||
}
|
||||
|
||||
const msg = await prisma.omniMessage.findFirst({
|
||||
where: { id: omniMessageId, teamId: auth.teamId, channel: "TELEGRAM", direction: "OUT" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!msg) {
|
||||
throw createError({ statusCode: 404, statusMessage: "telegram outbound message not found" });
|
||||
}
|
||||
|
||||
const attempts = Math.max(1, Math.min(Number(body?.attempts ?? 12), 50));
|
||||
const job = await enqueueTelegramSend({ omniMessageId }, { attempts });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
queue: "omni-outbound",
|
||||
jobId: job.id,
|
||||
omniMessageId,
|
||||
};
|
||||
});
|
||||
182
frontend/server/api/pilot-chat.post.ts
Normal file
182
frontend/server/api/pilot-chat.post.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { readBody } from "h3";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||
import { getAuthContext } from "../utils/auth";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
|
||||
import { persistChatMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent";
|
||||
import type { ChangeSet } from "../utils/changeSet";
|
||||
|
||||
function extractMessageText(message: any): string {
|
||||
if (!message || !Array.isArray(message.parts)) return "";
|
||||
return message.parts
|
||||
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
||||
.map((part: any) => part.text)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getLastUserText(messages: any[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message?.role !== "user") continue;
|
||||
const text = extractMessageText(message);
|
||||
if (text) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function humanizeTraceText(trace: AgentTraceEvent): string {
|
||||
if (trace.toolRun?.name) {
|
||||
return `Использую инструмент: ${trace.toolRun.name}`;
|
||||
}
|
||||
|
||||
const text = (trace.text ?? "").trim();
|
||||
if (!text) return "Агент работает с данными CRM.";
|
||||
|
||||
if (text.toLowerCase().includes("ошиб")) return "Возникла ошибка шага, пробую другой путь.";
|
||||
if (text.toLowerCase().includes("итог")) return "Готовлю финальный ответ.";
|
||||
return text;
|
||||
}
|
||||
|
||||
function renderChangeSetSummary(changeSet: ChangeSet): string {
|
||||
const totals = { created: 0, updated: 0, deleted: 0 };
|
||||
for (const item of changeSet.items) {
|
||||
if (item.action === "created") totals.created += 1;
|
||||
else if (item.action === "updated") totals.updated += 1;
|
||||
else if (item.action === "deleted") totals.deleted += 1;
|
||||
}
|
||||
|
||||
const byEntity = new Map<string, number>();
|
||||
for (const item of changeSet.items) {
|
||||
byEntity.set(item.entity, (byEntity.get(item.entity) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"Technical change summary",
|
||||
`Total: ${changeSet.items.length} · Created: ${totals.created} · Updated: ${totals.updated} · Archived: ${totals.deleted}`,
|
||||
...[...byEntity.entries()].map(([entity, count]) => `- ${entity}: ${count}`),
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const body = await readBody<{ messages?: any[] }>(event);
|
||||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||
const userText = getLastUserText(messages);
|
||||
|
||||
if (!userText) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
|
||||
}
|
||||
const requestId = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
const textId = `text-${Date.now()}`;
|
||||
writer.write({ type: "start" });
|
||||
try {
|
||||
const snapshotBefore = await captureSnapshot(prisma, auth.teamId);
|
||||
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: auth.userId,
|
||||
role: "USER",
|
||||
text: userText,
|
||||
requestId,
|
||||
eventType: "user",
|
||||
phase: "final",
|
||||
transient: false,
|
||||
});
|
||||
|
||||
const reply = await runCrmAgentFor({
|
||||
teamId: auth.teamId,
|
||||
userId: auth.userId,
|
||||
userText,
|
||||
requestId,
|
||||
conversationId: auth.conversationId,
|
||||
onTrace: async (trace: AgentTraceEvent) => {
|
||||
writer.write({
|
||||
type: "data-agent-log",
|
||||
data: {
|
||||
requestId,
|
||||
at: new Date().toISOString(),
|
||||
text: humanizeTraceText(trace),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const snapshotAfter = await captureSnapshot(prisma, auth.teamId);
|
||||
const changeSet = buildChangeSet(snapshotBefore, snapshotAfter);
|
||||
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: null,
|
||||
role: "ASSISTANT",
|
||||
text: reply.text,
|
||||
requestId,
|
||||
eventType: "assistant",
|
||||
phase: "final",
|
||||
transient: false,
|
||||
});
|
||||
|
||||
if (changeSet) {
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: null,
|
||||
role: "ASSISTANT",
|
||||
text: renderChangeSetSummary(changeSet),
|
||||
requestId,
|
||||
eventType: "note",
|
||||
phase: "final",
|
||||
transient: false,
|
||||
messageKind: "change_set_summary",
|
||||
changeSet,
|
||||
});
|
||||
}
|
||||
|
||||
writer.write({ type: "text-start", id: textId });
|
||||
writer.write({ type: "text-delta", id: textId, delta: reply.text });
|
||||
writer.write({ type: "text-end", id: textId });
|
||||
writer.write({ type: "finish", finishReason: "stop" });
|
||||
} catch (error: any) {
|
||||
const errorText = String(error?.message ?? error);
|
||||
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: null,
|
||||
role: "ASSISTANT",
|
||||
text: errorText,
|
||||
requestId,
|
||||
eventType: "assistant",
|
||||
phase: "error",
|
||||
transient: false,
|
||||
});
|
||||
|
||||
writer.write({
|
||||
type: "data-agent-log",
|
||||
data: {
|
||||
requestId,
|
||||
at: new Date().toISOString(),
|
||||
text: "Ошибка выполнения агентского цикла.",
|
||||
},
|
||||
});
|
||||
writer.write({ type: "text-start", id: textId });
|
||||
writer.write({
|
||||
type: "text-delta",
|
||||
id: textId,
|
||||
delta: errorText,
|
||||
});
|
||||
writer.write({ type: "text-end", id: textId });
|
||||
writer.write({ type: "finish", finishReason: "stop" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
});
|
||||
62
frontend/server/api/pilot-transcribe.post.ts
Normal file
62
frontend/server/api/pilot-transcribe.post.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { readBody } from "h3";
|
||||
import { getAuthContext } from "../utils/auth";
|
||||
import { transcribeWithWhisper } from "../utils/whisper";
|
||||
|
||||
type TranscribeBody = {
|
||||
audioBase64?: string;
|
||||
sampleRate?: number;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
function decodeBase64Pcm16(audioBase64: string) {
|
||||
const pcmBuffer = Buffer.from(audioBase64, "base64");
|
||||
if (pcmBuffer.length < 2) return new Float32Array();
|
||||
|
||||
const sampleCount = Math.floor(pcmBuffer.length / 2);
|
||||
const out = new Float32Array(sampleCount);
|
||||
|
||||
for (let i = 0; i < sampleCount; i += 1) {
|
||||
const lo = pcmBuffer[i * 2]!;
|
||||
const hi = pcmBuffer[i * 2 + 1]!;
|
||||
const int16 = (hi << 8) | lo;
|
||||
const signed = int16 >= 0x8000 ? int16 - 0x10000 : int16;
|
||||
out[i] = signed / 32768;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await getAuthContext(event);
|
||||
|
||||
const body = await readBody<TranscribeBody>(event);
|
||||
const audioBase64 = String(body?.audioBase64 ?? "").trim();
|
||||
const sampleRateRaw = Number(body?.sampleRate ?? 0);
|
||||
const language = String(body?.language ?? "").trim() || undefined;
|
||||
|
||||
if (!audioBase64) {
|
||||
throw createError({ statusCode: 400, statusMessage: "audioBase64 is required" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(sampleRateRaw) || sampleRateRaw < 8000 || sampleRateRaw > 48000) {
|
||||
throw createError({ statusCode: 400, statusMessage: "sampleRate must be between 8000 and 48000" });
|
||||
}
|
||||
|
||||
const samples = decodeBase64Pcm16(audioBase64);
|
||||
if (!samples.length) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Audio is empty" });
|
||||
}
|
||||
|
||||
const maxSamples = Math.floor(sampleRateRaw * 120);
|
||||
if (samples.length > maxSamples) {
|
||||
throw createError({ statusCode: 413, statusMessage: "Audio is too long (max 120s)" });
|
||||
}
|
||||
|
||||
const text = await transcribeWithWhisper({
|
||||
samples,
|
||||
sampleRate: sampleRateRaw,
|
||||
language,
|
||||
});
|
||||
|
||||
return { text };
|
||||
});
|
||||
156
frontend/server/dataset/exporter.ts
Normal file
156
frontend/server/dataset/exporter.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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: {
|
||||
kind: true,
|
||||
direction: true,
|
||||
channel: true,
|
||||
content: true,
|
||||
durationSec: true,
|
||||
transcriptJson: true,
|
||||
occurredAt: true,
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
},
|
||||
events: {
|
||||
select: { title: true, startsAt: true, endsAt: true, isArchived: 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,
|
||||
country: c.country ?? null,
|
||||
location: c.location ?? null,
|
||||
avatarUrl: c.avatarUrl ?? 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({
|
||||
kind: m.kind,
|
||||
direction: m.direction,
|
||||
channel: m.channel,
|
||||
occurredAt: m.occurredAt,
|
||||
content: m.content,
|
||||
durationSec: m.durationSec ?? null,
|
||||
transcript: m.transcriptJson ?? null,
|
||||
}),
|
||||
);
|
||||
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,
|
||||
isArchived: e.isArchived,
|
||||
note: e.note ?? null,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(evFile, evLines.join(""), "utf8");
|
||||
|
||||
const lastMessageAt = c.messages.at(-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);
|
||||
}
|
||||
9
frontend/server/disabled_plugins/queues.ts
Normal file
9
frontend/server/disabled_plugins/queues.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { startTelegramSendWorker } from "../queues/telegramSend";
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
// Disabled by default. If you need background processing, wire it explicitly.
|
||||
if (process.env.RUN_QUEUE_WORKER !== "1") return;
|
||||
|
||||
startTelegramSendWorker();
|
||||
});
|
||||
|
||||
1157
frontend/server/graphql/schema.ts
Normal file
1157
frontend/server/graphql/schema.ts
Normal file
File diff suppressed because it is too large
Load Diff
212
frontend/server/queues/outboundDelivery.ts
Normal file
212
frontend/server/queues/outboundDelivery.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Queue, Worker, type JobsOptions, type ConnectionOptions } from "bullmq";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
export const OUTBOUND_DELIVERY_QUEUE_NAME = "omni-outbound";
|
||||
|
||||
export type OutboundDeliveryJob = {
|
||||
omniMessageId: string;
|
||||
endpoint: string;
|
||||
method?: "POST" | "PUT" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
payload: unknown;
|
||||
channel?: string;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
function redisConnectionFromEnv(): ConnectionOptions {
|
||||
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
|
||||
const parsed = new URL(raw);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : 6379,
|
||||
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
||||
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
||||
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureHttpUrl(value: string) {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) throw new Error("endpoint is required");
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Unsupported endpoint protocol: ${parsed.protocol}`);
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function compactError(error: unknown) {
|
||||
if (!error) return "unknown_error";
|
||||
if (typeof error === "string") return error;
|
||||
const anyErr = error as any;
|
||||
return String(anyErr?.message ?? anyErr);
|
||||
}
|
||||
|
||||
function extractProviderMessageId(body: unknown): string | null {
|
||||
const obj = body as any;
|
||||
if (!obj || typeof obj !== "object") return null;
|
||||
const candidate =
|
||||
obj?.message_id ??
|
||||
obj?.messageId ??
|
||||
obj?.id ??
|
||||
obj?.result?.message_id ??
|
||||
obj?.result?.id ??
|
||||
null;
|
||||
if (candidate == null) return null;
|
||||
return String(candidate);
|
||||
}
|
||||
|
||||
export function outboundDeliveryQueue() {
|
||||
return new Queue<OutboundDeliveryJob, unknown, "deliver">(OUTBOUND_DELIVERY_QUEUE_NAME, {
|
||||
connection: redisConnectionFromEnv(),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: { count: 1000 },
|
||||
removeOnFail: { count: 5000 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?: JobsOptions) {
|
||||
const endpoint = ensureHttpUrl(input.endpoint);
|
||||
const q = outboundDeliveryQueue();
|
||||
|
||||
const payload = (input.payload ?? null) as Prisma.InputJsonValue;
|
||||
// Keep source message in pending before actual send starts.
|
||||
await prisma.omniMessage.update({
|
||||
where: { id: input.omniMessageId },
|
||||
data: {
|
||||
status: "PENDING",
|
||||
rawJson: {
|
||||
queue: {
|
||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
},
|
||||
deliveryRequest: {
|
||||
endpoint,
|
||||
method: input.method ?? "POST",
|
||||
channel: input.channel ?? null,
|
||||
provider: input.provider ?? null,
|
||||
payload,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return q.add("deliver", { ...input, endpoint }, {
|
||||
jobId: `omni:${input.omniMessageId}`,
|
||||
attempts: 12,
|
||||
backoff: { type: "exponential", delay: 1000 },
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
export function startOutboundDeliveryWorker() {
|
||||
return new Worker<OutboundDeliveryJob, unknown, "deliver">(
|
||||
OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
async (job) => {
|
||||
const msg = await prisma.omniMessage.findUnique({
|
||||
where: { id: job.data.omniMessageId },
|
||||
include: { thread: true },
|
||||
});
|
||||
if (!msg) return;
|
||||
|
||||
// Idempotency: if already sent/delivered, do not resend.
|
||||
if ((msg.status === "SENT" || msg.status === "DELIVERED" || msg.status === "READ") && msg.providerMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = ensureHttpUrl(job.data.endpoint);
|
||||
const method = job.data.method ?? "POST";
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
...(job.data.headers ?? {}),
|
||||
};
|
||||
|
||||
const requestPayload = (job.data.payload ?? null) as Prisma.InputJsonValue;
|
||||
const requestStartedAt = new Date().toISOString();
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(requestPayload ?? {}),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const responseBody = (() => {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${typeof responseBody === "string" ? responseBody : JSON.stringify(responseBody)}`);
|
||||
}
|
||||
|
||||
const providerMessageId = extractProviderMessageId(responseBody);
|
||||
await prisma.omniMessage.update({
|
||||
where: { id: msg.id },
|
||||
data: {
|
||||
status: "SENT",
|
||||
providerMessageId,
|
||||
rawJson: {
|
||||
queue: {
|
||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
completedAt: new Date().toISOString(),
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
},
|
||||
deliveryRequest: {
|
||||
endpoint,
|
||||
method,
|
||||
channel: job.data.channel ?? null,
|
||||
provider: job.data.provider ?? null,
|
||||
startedAt: requestStartedAt,
|
||||
payload: requestPayload,
|
||||
},
|
||||
deliveryResponse: {
|
||||
status: response.status,
|
||||
body: responseBody,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const isLastAttempt =
|
||||
typeof job.opts.attempts === "number" && job.attemptsMade + 1 >= job.opts.attempts;
|
||||
|
||||
if (isLastAttempt) {
|
||||
await prisma.omniMessage.update({
|
||||
where: { id: msg.id },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
rawJson: {
|
||||
queue: {
|
||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
failedAt: new Date().toISOString(),
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
},
|
||||
deliveryRequest: {
|
||||
endpoint,
|
||||
method,
|
||||
channel: job.data.channel ?? null,
|
||||
provider: job.data.provider ?? null,
|
||||
startedAt: requestStartedAt,
|
||||
payload: requestPayload,
|
||||
},
|
||||
deliveryError: {
|
||||
message: compactError(error),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ connection: redisConnectionFromEnv() },
|
||||
);
|
||||
}
|
||||
43
frontend/server/queues/telegramSend.ts
Normal file
43
frontend/server/queues/telegramSend.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { JobsOptions } from "bullmq";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { telegramApiBase, requireTelegramBotToken } from "../utils/telegram";
|
||||
import { enqueueOutboundDelivery, startOutboundDeliveryWorker } from "./outboundDelivery";
|
||||
|
||||
type TelegramSendJob = {
|
||||
omniMessageId: string;
|
||||
};
|
||||
|
||||
export async function enqueueTelegramSend(input: TelegramSendJob, opts?: JobsOptions) {
|
||||
const msg = await prisma.omniMessage.findUnique({
|
||||
where: { id: input.omniMessageId },
|
||||
include: { thread: true },
|
||||
});
|
||||
if (!msg) throw new Error(`omni message not found: ${input.omniMessageId}`);
|
||||
if (msg.channel !== "TELEGRAM" || msg.direction !== "OUT") {
|
||||
throw new Error(`Invalid omni message for telegram send: ${msg.id}`);
|
||||
}
|
||||
|
||||
const token = requireTelegramBotToken();
|
||||
const endpoint = `${telegramApiBase()}/bot${token}/sendMessage`;
|
||||
const payload = {
|
||||
chat_id: msg.thread.externalChatId,
|
||||
text: msg.text,
|
||||
...(msg.thread.businessConnectionId ? { business_connection_id: msg.thread.businessConnectionId } : {}),
|
||||
};
|
||||
|
||||
return enqueueOutboundDelivery(
|
||||
{
|
||||
omniMessageId: msg.id,
|
||||
endpoint,
|
||||
method: "POST",
|
||||
payload,
|
||||
provider: "telegram_business",
|
||||
channel: "TELEGRAM",
|
||||
},
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function startTelegramSendWorker() {
|
||||
return startOutboundDeliveryWorker();
|
||||
}
|
||||
35
frontend/server/queues/worker.ts
Normal file
35
frontend/server/queues/worker.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { startOutboundDeliveryWorker } from "./outboundDelivery";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { getRedis } from "../utils/redis";
|
||||
|
||||
const worker = startOutboundDeliveryWorker();
|
||||
console.log("[delivery-worker] started queue omni:outbound");
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[delivery-worker] shutting down by ${signal}`);
|
||||
try {
|
||||
await worker.close();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
try {
|
||||
const redis = getRedis();
|
||||
await redis.quit();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
|
||||
101
frontend/server/utils/auth.ts
Normal file
101
frontend/server/utils/auth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { H3Event } from "h3";
|
||||
import { getCookie, setCookie, deleteCookie, getHeader } from "h3";
|
||||
import { prisma } from "./prisma";
|
||||
import { hashPassword } from "./password";
|
||||
|
||||
export type AuthContext = {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
const COOKIE_USER = "cf_user";
|
||||
const COOKIE_TEAM = "cf_team";
|
||||
const COOKIE_CONV = "cf_conv";
|
||||
|
||||
function cookieOpts() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
};
|
||||
}
|
||||
|
||||
export function clearAuthSession(event: H3Event) {
|
||||
deleteCookie(event, COOKIE_USER, { path: "/" });
|
||||
deleteCookie(event, COOKIE_TEAM, { path: "/" });
|
||||
deleteCookie(event, COOKIE_CONV, { path: "/" });
|
||||
}
|
||||
|
||||
export function setSession(event: H3Event, ctx: AuthContext) {
|
||||
setCookie(event, COOKIE_USER, ctx.userId, cookieOpts());
|
||||
setCookie(event, COOKIE_TEAM, ctx.teamId, cookieOpts());
|
||||
setCookie(event, COOKIE_CONV, ctx.conversationId, cookieOpts());
|
||||
}
|
||||
|
||||
export async function getAuthContext(event: H3Event): Promise<AuthContext> {
|
||||
const cookieUser = getCookie(event, COOKIE_USER)?.trim();
|
||||
const cookieTeam = getCookie(event, COOKIE_TEAM)?.trim();
|
||||
const cookieConv = getCookie(event, COOKIE_CONV)?.trim();
|
||||
|
||||
// Temporary compatibility: allow passing via headers for debugging/dev tools.
|
||||
const hdrTeam = getHeader(event, "x-team-id")?.trim();
|
||||
const hdrUser = getHeader(event, "x-user-id")?.trim();
|
||||
const hdrConv = getHeader(event, "x-conversation-id")?.trim();
|
||||
|
||||
const hasAnySession = Boolean(cookieUser || cookieTeam || cookieConv || hdrTeam || hdrUser || hdrConv);
|
||||
if (!hasAnySession) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const userId = cookieUser || hdrUser;
|
||||
const teamId = cookieTeam || hdrTeam;
|
||||
const conversationId = cookieConv || hdrConv;
|
||||
|
||||
if (!userId || !teamId || !conversationId) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
const team = await prisma.team.findUnique({ where: { id: teamId } });
|
||||
|
||||
if (!user || !team) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const conv = await prisma.chatConversation.findFirst({
|
||||
where: { id: conversationId, teamId: team.id, createdByUserId: user.id },
|
||||
});
|
||||
|
||||
if (!conv) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
return { teamId: team.id, userId: user.id, conversationId: conv.id };
|
||||
}
|
||||
|
||||
export async function ensureDemoAuth() {
|
||||
const demoPasswordHash = hashPassword("DemoPass123!");
|
||||
const user = await prisma.user.upsert({
|
||||
where: { id: "demo-user" },
|
||||
update: { phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
||||
create: { id: "demo-user", phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
||||
});
|
||||
const team = 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 conv = 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: conv.id };
|
||||
}
|
||||
426
frontend/server/utils/changeSet.ts
Normal file
426
frontend/server/utils/changeSet.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
type CalendarSnapshotRow = {
|
||||
id: string;
|
||||
teamId: string;
|
||||
contactId: string | null;
|
||||
title: string;
|
||||
startsAt: string;
|
||||
endsAt: string | null;
|
||||
note: string | null;
|
||||
isArchived: boolean;
|
||||
archiveNote: string | null;
|
||||
archivedAt: string | null;
|
||||
};
|
||||
|
||||
type ContactNoteSnapshotRow = {
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type MessageSnapshotRow = {
|
||||
id: string;
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
kind: string;
|
||||
direction: string;
|
||||
channel: string;
|
||||
content: string;
|
||||
durationSec: number | null;
|
||||
occurredAt: string;
|
||||
};
|
||||
|
||||
type DealSnapshotRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
contactName: string;
|
||||
stage: string;
|
||||
nextStep: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
|
||||
export type SnapshotState = {
|
||||
calendarById: Map<string, CalendarSnapshotRow>;
|
||||
noteByContactId: Map<string, ContactNoteSnapshotRow>;
|
||||
messageById: Map<string, MessageSnapshotRow>;
|
||||
dealById: Map<string, DealSnapshotRow>;
|
||||
};
|
||||
|
||||
export type ChangeItem = {
|
||||
entity: "calendar_event" | "contact_note" | "message" | "deal";
|
||||
action: "created" | "updated" | "deleted";
|
||||
title: string;
|
||||
before: string;
|
||||
after: string;
|
||||
};
|
||||
|
||||
type UndoOp =
|
||||
| { kind: "delete_calendar_event"; id: string }
|
||||
| { kind: "restore_calendar_event"; data: CalendarSnapshotRow }
|
||||
| { kind: "delete_contact_message"; id: string }
|
||||
| { kind: "restore_contact_message"; data: MessageSnapshotRow }
|
||||
| { kind: "restore_contact_note"; contactId: string; content: string | null }
|
||||
| { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null };
|
||||
|
||||
export type ChangeSet = {
|
||||
id: string;
|
||||
status: "pending" | "confirmed" | "rolled_back";
|
||||
createdAt: string;
|
||||
summary: string;
|
||||
items: ChangeItem[];
|
||||
undo: UndoOp[];
|
||||
};
|
||||
|
||||
function fmt(val: string | null | undefined) {
|
||||
return (val ?? "").trim();
|
||||
}
|
||||
|
||||
function toCalendarText(row: CalendarSnapshotRow) {
|
||||
const when = new Date(row.startsAt).toLocaleString("ru-RU");
|
||||
return `${row.title} · ${when}${row.note ? ` · ${row.note}` : ""}`;
|
||||
}
|
||||
|
||||
function toMessageText(row: MessageSnapshotRow) {
|
||||
const when = new Date(row.occurredAt).toLocaleString("ru-RU");
|
||||
return `${row.contactName} · ${row.channel} · ${row.kind.toLowerCase()} · ${when} · ${row.content}`;
|
||||
}
|
||||
|
||||
function toDealText(row: DealSnapshotRow) {
|
||||
return `${row.title} (${row.contactName}) · ${row.stage}${row.nextStep ? ` · next: ${row.nextStep}` : ""}`;
|
||||
}
|
||||
|
||||
export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise<SnapshotState> {
|
||||
const [calendar, notes, messages, deals] = await Promise.all([
|
||||
prisma.calendarEvent.findMany({
|
||||
where: { teamId },
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
contactId: true,
|
||||
title: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
note: true,
|
||||
isArchived: true,
|
||||
archiveNote: true,
|
||||
archivedAt: true,
|
||||
},
|
||||
take: 4000,
|
||||
}),
|
||||
prisma.contactNote.findMany({
|
||||
where: { contact: { teamId } },
|
||||
select: { contactId: true, content: true, contact: { select: { name: true } } },
|
||||
take: 4000,
|
||||
}),
|
||||
prisma.contactMessage.findMany({
|
||||
where: { contact: { teamId } },
|
||||
include: { contact: { select: { name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 6000,
|
||||
}),
|
||||
prisma.deal.findMany({
|
||||
where: { teamId },
|
||||
include: { contact: { select: { name: true } } },
|
||||
take: 4000,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
calendarById: new Map(
|
||||
calendar.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
teamId: row.teamId,
|
||||
contactId: row.contactId ?? null,
|
||||
title: row.title,
|
||||
startsAt: row.startsAt.toISOString(),
|
||||
endsAt: row.endsAt?.toISOString() ?? null,
|
||||
note: row.note ?? null,
|
||||
isArchived: Boolean(row.isArchived),
|
||||
archiveNote: row.archiveNote ?? null,
|
||||
archivedAt: row.archivedAt?.toISOString() ?? null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
noteByContactId: new Map(
|
||||
notes.map((row) => [
|
||||
row.contactId,
|
||||
{
|
||||
contactId: row.contactId,
|
||||
contactName: row.contact.name,
|
||||
content: row.content,
|
||||
},
|
||||
]),
|
||||
),
|
||||
messageById: new Map(
|
||||
messages.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
contactId: row.contactId,
|
||||
contactName: row.contact.name,
|
||||
kind: row.kind,
|
||||
direction: row.direction,
|
||||
channel: row.channel,
|
||||
content: row.content,
|
||||
durationSec: row.durationSec ?? null,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
},
|
||||
]),
|
||||
),
|
||||
dealById: new Map(
|
||||
deals.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
contactName: row.contact.name,
|
||||
stage: row.stage,
|
||||
nextStep: row.nextStep ?? null,
|
||||
summary: row.summary ?? null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChangeSet(before: SnapshotState, after: SnapshotState): ChangeSet | null {
|
||||
const items: ChangeItem[] = [];
|
||||
const undo: UndoOp[] = [];
|
||||
|
||||
for (const [id, row] of after.calendarById) {
|
||||
const prev = before.calendarById.get(id);
|
||||
if (!prev) {
|
||||
items.push({
|
||||
entity: "calendar_event",
|
||||
action: "created",
|
||||
title: `Event created: ${row.title}`,
|
||||
before: "",
|
||||
after: toCalendarText(row),
|
||||
});
|
||||
undo.push({ kind: "delete_calendar_event", id });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
prev.title !== row.title ||
|
||||
prev.startsAt !== row.startsAt ||
|
||||
prev.endsAt !== row.endsAt ||
|
||||
fmt(prev.note) !== fmt(row.note) ||
|
||||
prev.isArchived !== row.isArchived ||
|
||||
fmt(prev.archiveNote) !== fmt(row.archiveNote) ||
|
||||
fmt(prev.archivedAt) !== fmt(row.archivedAt) ||
|
||||
prev.contactId !== row.contactId
|
||||
) {
|
||||
items.push({
|
||||
entity: "calendar_event",
|
||||
action: "updated",
|
||||
title: `Event updated: ${row.title}`,
|
||||
before: toCalendarText(prev),
|
||||
after: toCalendarText(row),
|
||||
});
|
||||
undo.push({ kind: "restore_calendar_event", data: prev });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, row] of before.calendarById) {
|
||||
if (after.calendarById.has(id)) continue;
|
||||
items.push({
|
||||
entity: "calendar_event",
|
||||
action: "deleted",
|
||||
title: `Event archived: ${row.title}`,
|
||||
before: toCalendarText(row),
|
||||
after: "",
|
||||
});
|
||||
undo.push({ kind: "restore_calendar_event", data: row });
|
||||
}
|
||||
|
||||
for (const [contactId, row] of after.noteByContactId) {
|
||||
const prev = before.noteByContactId.get(contactId);
|
||||
if (!prev) {
|
||||
items.push({
|
||||
entity: "contact_note",
|
||||
action: "created",
|
||||
title: `Summary added: ${row.contactName}`,
|
||||
before: "",
|
||||
after: row.content,
|
||||
});
|
||||
undo.push({ kind: "restore_contact_note", contactId, content: null });
|
||||
continue;
|
||||
}
|
||||
if (prev.content !== row.content) {
|
||||
items.push({
|
||||
entity: "contact_note",
|
||||
action: "updated",
|
||||
title: `Summary updated: ${row.contactName}`,
|
||||
before: prev.content,
|
||||
after: row.content,
|
||||
});
|
||||
undo.push({ kind: "restore_contact_note", contactId, content: prev.content });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [contactId, row] of before.noteByContactId) {
|
||||
if (after.noteByContactId.has(contactId)) continue;
|
||||
items.push({
|
||||
entity: "contact_note",
|
||||
action: "deleted",
|
||||
title: `Summary cleared: ${row.contactName}`,
|
||||
before: row.content,
|
||||
after: "",
|
||||
});
|
||||
undo.push({ kind: "restore_contact_note", contactId, content: row.content });
|
||||
}
|
||||
|
||||
for (const [id, row] of after.messageById) {
|
||||
if (before.messageById.has(id)) continue;
|
||||
items.push({
|
||||
entity: "message",
|
||||
action: "created",
|
||||
title: `Message created: ${row.contactName}`,
|
||||
before: "",
|
||||
after: toMessageText(row),
|
||||
});
|
||||
undo.push({ kind: "delete_contact_message", id });
|
||||
}
|
||||
|
||||
for (const [id, row] of after.dealById) {
|
||||
const prev = before.dealById.get(id);
|
||||
if (!prev) continue;
|
||||
if (prev.stage !== row.stage || fmt(prev.nextStep) !== fmt(row.nextStep) || fmt(prev.summary) !== fmt(row.summary)) {
|
||||
items.push({
|
||||
entity: "deal",
|
||||
action: "updated",
|
||||
title: `Deal updated: ${row.title}`,
|
||||
before: toDealText(prev),
|
||||
after: toDealText(row),
|
||||
});
|
||||
undo.push({
|
||||
kind: "restore_deal",
|
||||
id,
|
||||
stage: prev.stage,
|
||||
nextStep: prev.nextStep,
|
||||
summary: prev.summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const created = items.filter((x) => x.action === "created").length;
|
||||
const updated = items.filter((x) => x.action === "updated").length;
|
||||
const deleted = items.filter((x) => x.action === "deleted").length;
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
summary: `Created: ${created}, Updated: ${updated}, Archived: ${deleted}`,
|
||||
items,
|
||||
undo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, changeSet: ChangeSet) {
|
||||
const ops = [...changeSet.undo].reverse();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const op of ops) {
|
||||
if (op.kind === "delete_calendar_event") {
|
||||
await tx.calendarEvent.deleteMany({ where: { id: op.id, teamId } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_calendar_event") {
|
||||
const row = op.data;
|
||||
await tx.calendarEvent.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
teamId: row.teamId,
|
||||
contactId: row.contactId,
|
||||
title: row.title,
|
||||
startsAt: new Date(row.startsAt),
|
||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
||||
note: row.note,
|
||||
isArchived: row.isArchived,
|
||||
archiveNote: row.archiveNote,
|
||||
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
teamId: row.teamId,
|
||||
contactId: row.contactId,
|
||||
title: row.title,
|
||||
startsAt: new Date(row.startsAt),
|
||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
||||
note: row.note,
|
||||
isArchived: row.isArchived,
|
||||
archiveNote: row.archiveNote,
|
||||
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "delete_contact_message") {
|
||||
await tx.contactMessage.deleteMany({ where: { id: op.id } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_contact_message") {
|
||||
const row = op.data;
|
||||
await tx.contactMessage.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
contactId: row.contactId,
|
||||
kind: row.kind as any,
|
||||
direction: row.direction as any,
|
||||
channel: row.channel as any,
|
||||
content: row.content,
|
||||
durationSec: row.durationSec,
|
||||
occurredAt: new Date(row.occurredAt),
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
contactId: row.contactId,
|
||||
kind: row.kind as any,
|
||||
direction: row.direction as any,
|
||||
channel: row.channel as any,
|
||||
content: row.content,
|
||||
durationSec: row.durationSec,
|
||||
occurredAt: new Date(row.occurredAt),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_contact_note") {
|
||||
const contact = await tx.contact.findFirst({ where: { id: op.contactId, teamId }, select: { id: true } });
|
||||
if (!contact) continue;
|
||||
if (op.content === null) {
|
||||
await tx.contactNote.deleteMany({ where: { contactId: op.contactId } });
|
||||
} else {
|
||||
await tx.contactNote.upsert({
|
||||
where: { contactId: op.contactId },
|
||||
update: { content: op.content },
|
||||
create: { contactId: op.contactId, content: op.content },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_deal") {
|
||||
await tx.deal.updateMany({
|
||||
where: { id: op.id, teamId },
|
||||
data: {
|
||||
stage: op.stage,
|
||||
nextStep: op.nextStep,
|
||||
summary: op.summary,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
29
frontend/server/utils/langfuse.ts
Normal file
29
frontend/server/utils/langfuse.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Langfuse } from "langfuse";
|
||||
|
||||
let client: Langfuse | null = null;
|
||||
|
||||
function isTruthy(value: string | undefined) {
|
||||
const v = (value ?? "").trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "yes" || v === "on";
|
||||
}
|
||||
|
||||
export function isLangfuseEnabled() {
|
||||
const enabledRaw = process.env.LANGFUSE_ENABLED;
|
||||
if (enabledRaw && !isTruthy(enabledRaw)) return false;
|
||||
return Boolean((process.env.LANGFUSE_PUBLIC_KEY ?? "").trim() && (process.env.LANGFUSE_SECRET_KEY ?? "").trim());
|
||||
}
|
||||
|
||||
export function getLangfuseClient() {
|
||||
if (!isLangfuseEnabled()) return null;
|
||||
if (client) return client;
|
||||
|
||||
client = new Langfuse({
|
||||
publicKey: (process.env.LANGFUSE_PUBLIC_KEY ?? "").trim(),
|
||||
secretKey: (process.env.LANGFUSE_SECRET_KEY ?? "").trim(),
|
||||
baseUrl: (process.env.LANGFUSE_BASE_URL ?? "http://langfuse-web:3000").trim(),
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
29
frontend/server/utils/password.ts
Normal file
29
frontend/server/utils/password.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const SCRYPT_KEY_LENGTH = 64;
|
||||
|
||||
export function normalizePhone(raw: string) {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const hasPlus = trimmed.startsWith("+");
|
||||
const digits = trimmed.replace(/\D/g, "");
|
||||
if (!digits) return "";
|
||||
return `${hasPlus ? "+" : ""}${digits}`;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string) {
|
||||
const salt = randomBytes(16).toString("base64url");
|
||||
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
|
||||
return `scrypt$${salt}$${digest}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, encodedHash: string) {
|
||||
const [algo, salt, digest] = (encodedHash ?? "").split("$");
|
||||
if (algo !== "scrypt" || !salt || !digest) return false;
|
||||
|
||||
const actual = scryptSync(password, salt, SCRYPT_KEY_LENGTH);
|
||||
const expected = Buffer.from(digest, "base64url");
|
||||
if (actual.byteLength !== expected.byteLength) return false;
|
||||
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
22
frontend/server/utils/redis.ts
Normal file
22
frontend/server/utils/redis.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __redis: Redis | undefined;
|
||||
}
|
||||
|
||||
export function getRedis() {
|
||||
if (globalThis.__redis) return globalThis.__redis;
|
||||
|
||||
const url = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
const client = new Redis(url, {
|
||||
maxRetriesPerRequest: null, // recommended for BullMQ
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__redis = client;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
29
frontend/server/utils/telegram.ts
Normal file
29
frontend/server/utils/telegram.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type TelegramUpdate = Record<string, any>;
|
||||
|
||||
export function telegramApiBase() {
|
||||
return process.env.TELEGRAM_API_BASE || "https://api.telegram.org";
|
||||
}
|
||||
|
||||
export function requireTelegramBotToken() {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
if (!token) throw new Error("TELEGRAM_BOT_TOKEN is required");
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function telegramBotApi<T>(method: string, body: unknown): Promise<T> {
|
||||
const token = requireTelegramBotToken();
|
||||
const res = await fetch(`${telegramApiBase()}/bot${token}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok || !json?.ok) {
|
||||
const desc = json?.description || `HTTP ${res.status}`;
|
||||
throw new Error(`Telegram API ${method} failed: ${desc}`);
|
||||
}
|
||||
|
||||
return json.result as T;
|
||||
}
|
||||
|
||||
53
frontend/server/utils/whisper.ts
Normal file
53
frontend/server/utils/whisper.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
type WhisperTranscribeInput = {
|
||||
samples: Float32Array;
|
||||
sampleRate: number;
|
||||
language?: string;
|
||||
};
|
||||
let whisperPipelinePromise: Promise<any> | null = null;
|
||||
let transformersPromise: Promise<any> | null = null;
|
||||
|
||||
function getWhisperModelId() {
|
||||
return (process.env.CF_WHISPER_MODEL ?? "Xenova/whisper-small").trim() || "Xenova/whisper-small";
|
||||
}
|
||||
|
||||
function getWhisperLanguage() {
|
||||
const value = (process.env.CF_WHISPER_LANGUAGE ?? "ru").trim();
|
||||
return value || "ru";
|
||||
}
|
||||
|
||||
async function getWhisperPipeline() {
|
||||
if (!transformersPromise) {
|
||||
transformersPromise = import("@xenova/transformers");
|
||||
}
|
||||
|
||||
const { env, pipeline } = await transformersPromise;
|
||||
|
||||
if (!whisperPipelinePromise) {
|
||||
env.allowRemoteModels = true;
|
||||
env.allowLocalModels = true;
|
||||
env.cacheDir = "/app/.data/transformers";
|
||||
|
||||
const modelId = getWhisperModelId();
|
||||
whisperPipelinePromise = pipeline("automatic-speech-recognition", modelId);
|
||||
}
|
||||
|
||||
return whisperPipelinePromise;
|
||||
}
|
||||
|
||||
export async function transcribeWithWhisper(input: WhisperTranscribeInput) {
|
||||
const transcriber = (await getWhisperPipeline()) as any;
|
||||
const result = await transcriber(
|
||||
input.samples,
|
||||
{
|
||||
sampling_rate: input.sampleRate,
|
||||
language: (input.language ?? getWhisperLanguage()) || "ru",
|
||||
task: "transcribe",
|
||||
chunk_length_s: 20,
|
||||
stride_length_s: 5,
|
||||
return_timestamps: false,
|
||||
},
|
||||
);
|
||||
|
||||
const text = String((result as any)?.text ?? "").trim();
|
||||
return text;
|
||||
}
|
||||
Reference in New Issue
Block a user