Refine CRM chat UX and add DB-backed pin toggle
This commit is contained in:
113
Frontend/app.vue
113
Frontend/app.vue
@@ -13,6 +13,7 @@ import chatConversationsQuery from "./graphql/operations/chat-conversations.grap
|
|||||||
import createChatConversationMutation from "./graphql/operations/create-chat-conversation.graphql?raw";
|
import createChatConversationMutation from "./graphql/operations/create-chat-conversation.graphql?raw";
|
||||||
import selectChatConversationMutation from "./graphql/operations/select-chat-conversation.graphql?raw";
|
import selectChatConversationMutation from "./graphql/operations/select-chat-conversation.graphql?raw";
|
||||||
import archiveChatConversationMutation from "./graphql/operations/archive-chat-conversation.graphql?raw";
|
import archiveChatConversationMutation from "./graphql/operations/archive-chat-conversation.graphql?raw";
|
||||||
|
import toggleContactPinMutation from "./graphql/operations/toggle-contact-pin.graphql?raw";
|
||||||
import confirmLatestChangeSetMutation from "./graphql/operations/confirm-latest-change-set.graphql?raw";
|
import confirmLatestChangeSetMutation from "./graphql/operations/confirm-latest-change-set.graphql?raw";
|
||||||
import rollbackLatestChangeSetMutation from "./graphql/operations/rollback-latest-change-set.graphql?raw";
|
import rollbackLatestChangeSetMutation from "./graphql/operations/rollback-latest-change-set.graphql?raw";
|
||||||
import { Chat as AiChat } from "@ai-sdk/vue";
|
import { Chat as AiChat } from "@ai-sdk/vue";
|
||||||
@@ -66,6 +67,7 @@ type CommItem = {
|
|||||||
kind: "message" | "call";
|
kind: "message" | "call";
|
||||||
direction: "in" | "out";
|
direction: "in" | "out";
|
||||||
text: string;
|
text: string;
|
||||||
|
audioUrl?: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
transcript?: string[];
|
transcript?: string[];
|
||||||
};
|
};
|
||||||
@@ -283,10 +285,7 @@ let pilotWaveRecordPlugin: any = null;
|
|||||||
let pilotWaveMicSession: { onDestroy: () => void; onEnd: () => void } | null = null;
|
let pilotWaveMicSession: { onDestroy: () => void; onEnd: () => void } | null = null;
|
||||||
const commCallWaveHosts = new Map<string, HTMLDivElement>();
|
const commCallWaveHosts = new Map<string, HTMLDivElement>();
|
||||||
const commCallWaveSurfers = new Map<string, any>();
|
const commCallWaveSurfers = new Map<string, any>();
|
||||||
const CALL_AUDIO_SAMPLE_URLS = [
|
const FALLBACK_CALL_AUDIO_URL = "/audio-samples/national-road-9.m4a";
|
||||||
"/audio-samples/national-road-9.m4a",
|
|
||||||
"/audio-samples/meeting-recording-2026-02-11.webm",
|
|
||||||
];
|
|
||||||
const callTranscriptOpen = ref<Record<string, boolean>>({});
|
const callTranscriptOpen = ref<Record<string, boolean>>({});
|
||||||
const callTranscriptLoading = ref<Record<string, boolean>>({});
|
const callTranscriptLoading = ref<Record<string, boolean>>({});
|
||||||
const callTranscriptText = ref<Record<string, string>>({});
|
const callTranscriptText = ref<Record<string, string>>({});
|
||||||
@@ -330,6 +329,7 @@ const chatSwitching = ref(false);
|
|||||||
const chatCreating = ref(false);
|
const chatCreating = ref(false);
|
||||||
const chatArchivingId = ref("");
|
const chatArchivingId = ref("");
|
||||||
const chatThreadPickerOpen = ref(false);
|
const chatThreadPickerOpen = ref(false);
|
||||||
|
const commPinToggling = ref(false);
|
||||||
const selectedChatId = ref("");
|
const selectedChatId = ref("");
|
||||||
const loginPhone = ref("");
|
const loginPhone = ref("");
|
||||||
const loginPassword = ref("");
|
const loginPassword = ref("");
|
||||||
@@ -491,7 +491,7 @@ async function gqlFetch<TData>(query: string, variables?: Record<string, unknown
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.errors?.length) {
|
if (result.errors?.length) {
|
||||||
throw new Error(result.errors[0].message || "GraphQL request failed");
|
throw new Error(result.errors[0]?.message || "GraphQL request failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
@@ -739,13 +739,9 @@ function buildCallWavePeaks(item: CommItem, size = 320) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCallAudioUrl(itemId: string) {
|
function getCallAudioUrl(item?: CommItem) {
|
||||||
if (!CALL_AUDIO_SAMPLE_URLS.length) return "";
|
const direct = String(item?.audioUrl ?? "").trim();
|
||||||
let hash = 0;
|
return direct || FALLBACK_CALL_AUDIO_URL;
|
||||||
for (let i = 0; i < itemId.length; i += 1) {
|
|
||||||
hash = (hash * 33 + itemId.charCodeAt(i)) >>> 0;
|
|
||||||
}
|
|
||||||
return CALL_AUDIO_SAMPLE_URLS[hash % CALL_AUDIO_SAMPLE_URLS.length] ?? CALL_AUDIO_SAMPLE_URLS[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureCommCallWave(itemId: string) {
|
async function ensureCommCallWave(itemId: string) {
|
||||||
@@ -755,7 +751,7 @@ async function ensureCommCallWave(itemId: string) {
|
|||||||
|
|
||||||
const callItem = visibleThreadItems.value.find((item) => item.id === itemId && item.kind === "call");
|
const callItem = visibleThreadItems.value.find((item) => item.id === itemId && item.kind === "call");
|
||||||
if (!callItem) return;
|
if (!callItem) return;
|
||||||
const audioUrl = getCallAudioUrl(itemId);
|
const audioUrl = getCallAudioUrl(callItem);
|
||||||
|
|
||||||
const { WaveSurfer } = await loadWaveSurferModules();
|
const { WaveSurfer } = await loadWaveSurferModules();
|
||||||
const durationSeconds =
|
const durationSeconds =
|
||||||
@@ -1393,7 +1389,7 @@ const groupedContacts = computed(() => {
|
|||||||
const map = new Map<string, Contact[]>();
|
const map = new Map<string, Contact[]>();
|
||||||
|
|
||||||
for (const contact of filteredContacts.value) {
|
for (const contact of filteredContacts.value) {
|
||||||
const key = contact.name[0].toUpperCase();
|
const key = (contact.name[0] ?? "#").toUpperCase();
|
||||||
if (!map.has(key)) {
|
if (!map.has(key)) {
|
||||||
map.set(key, []);
|
map.set(key, []);
|
||||||
}
|
}
|
||||||
@@ -1411,7 +1407,8 @@ watchEffect(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!filteredContacts.value.some((item) => item.id === selectedContactId.value)) {
|
if (!filteredContacts.value.some((item) => item.id === selectedContactId.value)) {
|
||||||
selectedContactId.value = filteredContacts.value[0].id;
|
const first = filteredContacts.value[0];
|
||||||
|
if (first) selectedContactId.value = first.id;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1463,7 +1460,8 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!filteredDocuments.value.some((item) => item.id === selectedDocumentId.value)) {
|
if (!filteredDocuments.value.some((item) => item.id === selectedDocumentId.value)) {
|
||||||
selectedDocumentId.value = filteredDocuments.value[0].id;
|
const first = filteredDocuments.value[0];
|
||||||
|
if (first) selectedDocumentId.value = first.id;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1472,7 +1470,8 @@ const selectedDocument = computed(() => documents.value.find((item) => item.id =
|
|||||||
function openPilotInstructions() {
|
function openPilotInstructions() {
|
||||||
selectedTab.value = "documents";
|
selectedTab.value = "documents";
|
||||||
if (!selectedDocumentId.value && filteredDocuments.value.length) {
|
if (!selectedDocumentId.value && filteredDocuments.value.length) {
|
||||||
selectedDocumentId.value = filteredDocuments.value[0].id;
|
const first = filteredDocuments.value[0];
|
||||||
|
if (first) selectedDocumentId.value = first.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1560,7 +1559,8 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!commThreads.value.some((thread) => thread.id === selectedCommThreadId.value)) {
|
if (!commThreads.value.some((thread) => thread.id === selectedCommThreadId.value)) {
|
||||||
selectedCommThreadId.value = commThreads.value[0].id;
|
const first = commThreads.value[0];
|
||||||
|
if (first) selectedCommThreadId.value = first.id;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1765,6 +1765,51 @@ const latestPinnedLabel = computed(() => {
|
|||||||
return `${latestPinnedItem.value.event.title} · ${formatDay(latestPinnedItem.value.event.start)}`;
|
return `${latestPinnedItem.value.event.title} · ${formatDay(latestPinnedItem.value.event.start)}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function normalizePinText(value: string) {
|
||||||
|
return String(value ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPinnedText(contact: string, value: string) {
|
||||||
|
const contactName = String(contact ?? "").trim();
|
||||||
|
const text = normalizePinText(value);
|
||||||
|
if (!contactName || !text) return false;
|
||||||
|
return commPins.value.some((pin) => pin.contact === contactName && normalizePinText(pin.text) === text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryPinText(entry: any): string {
|
||||||
|
if (!entry) return "";
|
||||||
|
if (entry.kind === "pin") return normalizePinText(entry.text ?? "");
|
||||||
|
if (entry.kind === "recommendation") return normalizePinText(entry.card?.text ?? "");
|
||||||
|
if (entry.kind === "event" || entry.kind === "eventAlert" || entry.kind === "eventLog") {
|
||||||
|
return normalizePinText(entry.event?.note || entry.event?.title || "");
|
||||||
|
}
|
||||||
|
if (entry.kind === "call") return normalizePinText(entry.item?.text || "");
|
||||||
|
return normalizePinText(entry.item?.text || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePinnedText(contact: string, value: string) {
|
||||||
|
if (commPinToggling.value) return;
|
||||||
|
const contactName = String(contact ?? "").trim();
|
||||||
|
const text = normalizePinText(value);
|
||||||
|
if (!contactName || !text) return;
|
||||||
|
commPinToggling.value = true;
|
||||||
|
try {
|
||||||
|
await gqlFetch<{ toggleContactPin: { ok: boolean; pinned: boolean } }>(toggleContactPinMutation, {
|
||||||
|
contact: contactName,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
await refreshCrmData();
|
||||||
|
} finally {
|
||||||
|
commPinToggling.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePinForEntry(entry: any) {
|
||||||
|
const contact = selectedCommThread.value?.contact ?? "";
|
||||||
|
const text = entryPinText(entry);
|
||||||
|
await togglePinnedText(contact, text);
|
||||||
|
}
|
||||||
|
|
||||||
const selectedWorkspaceContact = computed(() => {
|
const selectedWorkspaceContact = computed(() => {
|
||||||
const threadContactId = (selectedCommThread.value?.id ?? "").trim();
|
const threadContactId = (selectedCommThread.value?.id ?? "").trim();
|
||||||
if (threadContactId) {
|
if (threadContactId) {
|
||||||
@@ -1880,6 +1925,11 @@ function formatDealStepMeta(step: DealStep) {
|
|||||||
return formatDealDeadline(parsed);
|
return formatDealDeadline(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatYearMonthFirst(item: { first?: CalendarEvent }) {
|
||||||
|
if (!item.first) return "";
|
||||||
|
return `${formatDay(item.first.start)} · ${item.first.title}`;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedWorkspaceDealDueDate = computed(() => {
|
const selectedWorkspaceDealDueDate = computed(() => {
|
||||||
const deal = selectedWorkspaceDeal.value;
|
const deal = selectedWorkspaceDeal.value;
|
||||||
if (!deal) return null;
|
if (!deal) return null;
|
||||||
@@ -1933,7 +1983,7 @@ async function transcribeCallItem(item: CommItem) {
|
|||||||
if (callTranscriptLoading.value[itemId]) return;
|
if (callTranscriptLoading.value[itemId]) return;
|
||||||
if (callTranscriptText.value[itemId]) return;
|
if (callTranscriptText.value[itemId]) return;
|
||||||
|
|
||||||
const audioUrl = getCallAudioUrl(itemId);
|
const audioUrl = getCallAudioUrl(item);
|
||||||
if (!audioUrl) {
|
if (!audioUrl) {
|
||||||
callTranscriptError.value[itemId] = "Audio source is missing";
|
callTranscriptError.value[itemId] = "Audio source is missing";
|
||||||
return;
|
return;
|
||||||
@@ -2803,18 +2853,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
<article class="min-h-0 rounded-xl border border-base-300 md:col-span-8">
|
<article class="min-h-0 rounded-xl border border-base-300 md:col-span-8">
|
||||||
<div v-if="selectedContact" class="p-3 md:p-4">
|
<div v-if="selectedContact" class="p-3 md:p-4">
|
||||||
<div class="border-b border-base-300 pb-2">
|
<div class="border-b border-base-300 pb-2">
|
||||||
<p class="font-medium">{{ selectedContact.name }}</p>
|
<p class="font-medium">{{ selectedContact!.name }}</p>
|
||||||
<p class="text-xs text-base-content/60">
|
<p class="text-xs text-base-content/60">
|
||||||
{{ selectedContact.company }} · {{ selectedContact.location }}, {{ selectedContact.country }}
|
{{ selectedContact!.company }} · {{ selectedContact!.location }}, {{ selectedContact!.country }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-base-content/55">Last contact · {{ formatStamp(selectedContact.lastContactAt) }}</p>
|
<p class="mt-1 text-xs text-base-content/55">Last contact · {{ formatStamp(selectedContact!.lastContactAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<ContactCollaborativeEditor
|
<ContactCollaborativeEditor
|
||||||
:key="`contact-editor-${selectedContact.id}`"
|
:key="`contact-editor-${selectedContact!.id}`"
|
||||||
v-model="selectedContact.description"
|
v-model="selectedContact!.description"
|
||||||
:room="`crm-contact-${selectedContact.id}`"
|
:room="`crm-contact-${selectedContact!.id}`"
|
||||||
placeholder="Describe contact context and next steps..."
|
placeholder="Describe contact context and next steps..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2846,7 +2896,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
<section class="rounded-xl border border-base-300 p-3">
|
<section class="rounded-xl border border-base-300 p-3">
|
||||||
<div class="mb-2 flex items-center justify-between gap-2">
|
<div class="mb-2 flex items-center justify-between gap-2">
|
||||||
<p class="text-sm font-semibold">Recent messages</p>
|
<p class="text-sm font-semibold">Recent messages</p>
|
||||||
<button class="btn btn-ghost btn-xs" @click="openCommunicationThread(selectedContact.name)">Open chat</button>
|
<button class="btn btn-ghost btn-xs" @click="openCommunicationThread(selectedContact!.name)">Open chat</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<button
|
<button
|
||||||
@@ -3142,7 +3192,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
<p class="font-medium">{{ item.label }}</p>
|
<p class="font-medium">{{ item.label }}</p>
|
||||||
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
||||||
<p v-if="item.first" class="mt-1 text-xs text-base-content/70">
|
<p v-if="item.first" class="mt-1 text-xs text-base-content/70">
|
||||||
{{ formatDay(item.first.start) }} · {{ item.first.title }}
|
{{ formatYearMonthFirst(item) }}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3171,11 +3221,14 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0 fill-current text-base-content/75">
|
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0 fill-current text-base-content/75">
|
||||||
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
|
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="min-w-0 flex-1 truncate text-xs text-base-content/80">{{ latestPinnedLabel }}</span>
|
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
|
||||||
<span class="shrink-0 text-[11px] text-base-content/60">{{ selectedCommPinnedStream.length }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)" :key="entry.id">
|
<div
|
||||||
|
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
|
||||||
|
:key="entry.id"
|
||||||
|
@contextmenu.prevent="togglePinForEntry(entry)"
|
||||||
|
>
|
||||||
<div v-if="entry.kind === 'pin'" class="flex justify-center">
|
<div v-if="entry.kind === 'pin'" class="flex justify-center">
|
||||||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
|
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-base-content/65">Pinned note</p>
|
<p class="text-[11px] font-semibold uppercase tracking-wide text-base-content/65">Pinned note</p>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ query DashboardQuery {
|
|||||||
kind
|
kind
|
||||||
direction
|
direction
|
||||||
text
|
text
|
||||||
|
audioUrl
|
||||||
duration
|
duration
|
||||||
transcript
|
transcript
|
||||||
}
|
}
|
||||||
|
|||||||
6
Frontend/graphql/operations/toggle-contact-pin.graphql
Normal file
6
Frontend/graphql/operations/toggle-contact-pin.graphql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mutation ToggleContactPinMutation($contact: String!, $text: String!) {
|
||||||
|
toggleContactPin(contact: $contact, text: $text) {
|
||||||
|
ok
|
||||||
|
pinned
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ export default defineNuxtConfig({
|
|||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
css: ["~/assets/css/main.css"],
|
css: ["~/assets/css/main.css"],
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss() as any],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ model ContactMessage {
|
|||||||
direction MessageDirection
|
direction MessageDirection
|
||||||
channel MessageChannel
|
channel MessageChannel
|
||||||
content String
|
content String
|
||||||
|
audioUrl String?
|
||||||
durationSec Int?
|
durationSec Int?
|
||||||
transcriptJson Json?
|
transcriptJson Json?
|
||||||
occurredAt DateTime @default(now())
|
occurredAt DateTime @default(now())
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ async function main() {
|
|||||||
direction: "OUT",
|
direction: "OUT",
|
||||||
channel: "PHONE",
|
channel: "PHONE",
|
||||||
content: "Созвон по уточнению: модули Odoo, потоки данных и AI-сценарии",
|
content: "Созвон по уточнению: модули Odoo, потоки данных и AI-сценарии",
|
||||||
|
audioUrl: "/audio-samples/national-road-9.m4a",
|
||||||
durationSec: 180 + ((i * 23) % 420),
|
durationSec: 180 + ((i * 23) % 420),
|
||||||
transcriptJson: [
|
transcriptJson: [
|
||||||
`${contact.name}: Нам нужен поэтапный запуск, начнём с продаж и склада.`,
|
`${contact.name}: Нам нужен поэтапный запуск, начнём с продаж и склада.`,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ChatOpenAI } from "@langchain/openai";
|
|||||||
import { tool } from "@langchain/core/tools";
|
import { tool } from "@langchain/core/tools";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getLangfuseClient } from "../utils/langfuse";
|
import { getLangfuseClient } from "../utils/langfuse";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
function iso(d: Date) {
|
function iso(d: Date) {
|
||||||
return d.toISOString();
|
return d.toISOString();
|
||||||
@@ -570,7 +571,7 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
channel: toChannel(change.channel),
|
channel: toChannel(change.channel),
|
||||||
content: change.text,
|
content: change.text,
|
||||||
durationSec: change.durationSec,
|
durationSec: change.durationSec,
|
||||||
transcriptJson: Array.isArray(change.transcript) ? change.transcript : null,
|
transcriptJson: Array.isArray(change.transcript) ? change.transcript : Prisma.JsonNull,
|
||||||
occurredAt: new Date(change.at),
|
occurredAt: new Date(change.at),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -598,7 +599,8 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const crmTool = tool(
|
const crmTool = tool(
|
||||||
async (raw: z.infer<typeof CrmToolSchema>) => {
|
async (rawInput: unknown) => {
|
||||||
|
const raw = CrmToolSchema.parse(rawInput);
|
||||||
const toolName = `crm:${raw.action}`;
|
const toolName = `crm:${raw.action}`;
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
toolsUsed.push(toolName);
|
toolsUsed.push(toolName);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId
|
|||||||
);
|
);
|
||||||
await fs.writeFile(evFile, evLines.join(""), "utf8");
|
await fs.writeFile(evFile, evLines.join(""), "utf8");
|
||||||
|
|
||||||
const lastMessageAt = c.messages.length ? c.messages[c.messages.length - 1].occurredAt : null;
|
const lastMessageAt = c.messages.at(-1)?.occurredAt ?? null;
|
||||||
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
|
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
|
||||||
|
|
||||||
contactIndex.push({
|
contactIndex.push({
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ async function getDashboard(auth: AuthContext | null) {
|
|||||||
kind: m.kind === "CALL" ? "call" : "message",
|
kind: m.kind === "CALL" ? "call" : "message",
|
||||||
direction: m.direction === "IN" ? "in" : "out",
|
direction: m.direction === "IN" ? "in" : "out",
|
||||||
text: m.content,
|
text: m.content,
|
||||||
|
audioUrl: m.audioUrl ?? "",
|
||||||
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
|
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
|
||||||
transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [],
|
transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [],
|
||||||
}));
|
}));
|
||||||
@@ -515,6 +516,7 @@ async function createCommunication(auth: AuthContext | null, input: {
|
|||||||
kind?: "message" | "call";
|
kind?: "message" | "call";
|
||||||
direction?: "in" | "out";
|
direction?: "in" | "out";
|
||||||
text?: string;
|
text?: string;
|
||||||
|
audioUrl?: string;
|
||||||
at?: string;
|
at?: string;
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
transcript?: string[];
|
transcript?: string[];
|
||||||
@@ -540,6 +542,7 @@ async function createCommunication(auth: AuthContext | null, input: {
|
|||||||
direction: input?.direction === "in" ? "IN" : "OUT",
|
direction: input?.direction === "in" ? "IN" : "OUT",
|
||||||
channel: toDbChannel(input?.channel ?? "Phone") as any,
|
channel: toDbChannel(input?.channel ?? "Phone") as any,
|
||||||
content: (input?.text ?? "").trim(),
|
content: (input?.text ?? "").trim(),
|
||||||
|
audioUrl: (input?.audioUrl ?? "").trim() || null,
|
||||||
durationSec: typeof input?.durationSec === "number" ? input.durationSec : null,
|
durationSec: typeof input?.durationSec === "number" ? input.durationSec : null,
|
||||||
transcriptJson: Array.isArray(input?.transcript) ? input.transcript : undefined,
|
transcriptJson: Array.isArray(input?.transcript) ? input.transcript : undefined,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
@@ -710,6 +713,41 @@ async function logPilotNote(auth: AuthContext | null, textInput: string) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleContactPin(auth: AuthContext | null, contactInput: string, textInput: string) {
|
||||||
|
const ctx = requireAuth(auth);
|
||||||
|
const contactName = (contactInput ?? "").trim();
|
||||||
|
const text = (textInput ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
if (!contactName) throw new Error("contact is required");
|
||||||
|
if (!text) throw new Error("text is required");
|
||||||
|
|
||||||
|
const contact = await prisma.contact.findFirst({
|
||||||
|
where: { teamId: ctx.teamId, name: contactName },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!contact) throw new Error("contact not found");
|
||||||
|
|
||||||
|
const existing = await prisma.contactPin.findFirst({
|
||||||
|
where: { teamId: ctx.teamId, contactId: contact.id, text },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.contactPin.deleteMany({
|
||||||
|
where: { teamId: ctx.teamId, contactId: contact.id, text },
|
||||||
|
});
|
||||||
|
return { ok: true, pinned: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.contactPin.create({
|
||||||
|
data: {
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
contactId: contact.id,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true, pinned: true };
|
||||||
|
}
|
||||||
|
|
||||||
export const crmGraphqlSchema = buildSchema(`
|
export const crmGraphqlSchema = buildSchema(`
|
||||||
type Query {
|
type Query {
|
||||||
me: MePayload!
|
me: MePayload!
|
||||||
@@ -728,6 +766,7 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
confirmLatestChangeSet: MutationResult!
|
confirmLatestChangeSet: MutationResult!
|
||||||
rollbackLatestChangeSet: MutationResult!
|
rollbackLatestChangeSet: MutationResult!
|
||||||
logPilotNote(text: String!): MutationResult!
|
logPilotNote(text: String!): MutationResult!
|
||||||
|
toggleContactPin(contact: String!, text: String!): PinToggleResult!
|
||||||
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
|
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
|
||||||
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
|
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
|
||||||
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
||||||
@@ -742,6 +781,11 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
id: ID!
|
id: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PinToggleResult {
|
||||||
|
ok: Boolean!
|
||||||
|
pinned: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
input CreateCalendarEventInput {
|
input CreateCalendarEventInput {
|
||||||
title: String!
|
title: String!
|
||||||
start: String!
|
start: String!
|
||||||
@@ -757,6 +801,7 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
kind: String
|
kind: String
|
||||||
direction: String
|
direction: String
|
||||||
text: String
|
text: String
|
||||||
|
audioUrl: String
|
||||||
at: String
|
at: String
|
||||||
durationSec: Int
|
durationSec: Int
|
||||||
transcript: [String!]
|
transcript: [String!]
|
||||||
@@ -853,6 +898,7 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
kind: String!
|
kind: String!
|
||||||
direction: String!
|
direction: String!
|
||||||
text: String!
|
text: String!
|
||||||
|
audioUrl: String!
|
||||||
duration: String!
|
duration: String!
|
||||||
transcript: [String!]!
|
transcript: [String!]!
|
||||||
}
|
}
|
||||||
@@ -958,6 +1004,9 @@ export const crmGraphqlRoot = {
|
|||||||
logPilotNote: async (args: { text: string }, context: GraphQLContext) =>
|
logPilotNote: async (args: { text: string }, context: GraphQLContext) =>
|
||||||
logPilotNote(context.auth, args.text),
|
logPilotNote(context.auth, args.text),
|
||||||
|
|
||||||
|
toggleContactPin: async (args: { contact: string; text: string }, context: GraphQLContext) =>
|
||||||
|
toggleContactPin(context.auth, args.contact, args.text),
|
||||||
|
|
||||||
createCalendarEvent: async (args: { input: { title: string; start: string; end?: string; contact?: string; note?: string; status?: string } }, context: GraphQLContext) =>
|
createCalendarEvent: async (args: { input: { title: string; start: string; end?: string; contact?: string; note?: string; status?: string } }, context: GraphQLContext) =>
|
||||||
createCalendarEvent(context.auth, args.input),
|
createCalendarEvent(context.auth, args.input),
|
||||||
|
|
||||||
@@ -969,6 +1018,7 @@ export const crmGraphqlRoot = {
|
|||||||
kind?: "message" | "call";
|
kind?: "message" | "call";
|
||||||
direction?: "in" | "out";
|
direction?: "in" | "out";
|
||||||
text?: string;
|
text?: string;
|
||||||
|
audioUrl?: string;
|
||||||
at?: string;
|
at?: string;
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
transcript?: string[];
|
transcript?: string[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Queue, Worker, type JobsOptions } from "bullmq";
|
import { Queue, Worker, type JobsOptions, type ConnectionOptions } from "bullmq";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
import { getRedis } from "../utils/redis";
|
|
||||||
|
|
||||||
export const OUTBOUND_DELIVERY_QUEUE_NAME = "omni-outbound";
|
export const OUTBOUND_DELIVERY_QUEUE_NAME = "omni-outbound";
|
||||||
|
|
||||||
@@ -15,6 +15,19 @@ export type OutboundDeliveryJob = {
|
|||||||
provider?: 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) {
|
function ensureHttpUrl(value: string) {
|
||||||
const raw = (value ?? "").trim();
|
const raw = (value ?? "").trim();
|
||||||
if (!raw) throw new Error("endpoint is required");
|
if (!raw) throw new Error("endpoint is required");
|
||||||
@@ -47,8 +60,8 @@ function extractProviderMessageId(body: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function outboundDeliveryQueue() {
|
export function outboundDeliveryQueue() {
|
||||||
return new Queue<OutboundDeliveryJob>(OUTBOUND_DELIVERY_QUEUE_NAME, {
|
return new Queue<OutboundDeliveryJob, unknown, "deliver">(OUTBOUND_DELIVERY_QUEUE_NAME, {
|
||||||
connection: getRedis(),
|
connection: redisConnectionFromEnv(),
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
removeOnComplete: { count: 1000 },
|
removeOnComplete: { count: 1000 },
|
||||||
removeOnFail: { count: 5000 },
|
removeOnFail: { count: 5000 },
|
||||||
@@ -60,6 +73,7 @@ export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?:
|
|||||||
const endpoint = ensureHttpUrl(input.endpoint);
|
const endpoint = ensureHttpUrl(input.endpoint);
|
||||||
const q = outboundDeliveryQueue();
|
const q = outboundDeliveryQueue();
|
||||||
|
|
||||||
|
const payload = (input.payload ?? null) as Prisma.InputJsonValue;
|
||||||
// Keep source message in pending before actual send starts.
|
// Keep source message in pending before actual send starts.
|
||||||
await prisma.omniMessage.update({
|
await prisma.omniMessage.update({
|
||||||
where: { id: input.omniMessageId },
|
where: { id: input.omniMessageId },
|
||||||
@@ -75,7 +89,7 @@ export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?:
|
|||||||
method: input.method ?? "POST",
|
method: input.method ?? "POST",
|
||||||
channel: input.channel ?? null,
|
channel: input.channel ?? null,
|
||||||
provider: input.provider ?? null,
|
provider: input.provider ?? null,
|
||||||
payload: input.payload,
|
payload,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -90,7 +104,7 @@ export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startOutboundDeliveryWorker() {
|
export function startOutboundDeliveryWorker() {
|
||||||
return new Worker<OutboundDeliveryJob>(
|
return new Worker<OutboundDeliveryJob, unknown, "deliver">(
|
||||||
OUTBOUND_DELIVERY_QUEUE_NAME,
|
OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
const msg = await prisma.omniMessage.findUnique({
|
const msg = await prisma.omniMessage.findUnique({
|
||||||
@@ -112,12 +126,13 @@ export function startOutboundDeliveryWorker() {
|
|||||||
...(job.data.headers ?? {}),
|
...(job.data.headers ?? {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestPayload = (job.data.payload ?? null) as Prisma.InputJsonValue;
|
||||||
const requestStartedAt = new Date().toISOString();
|
const requestStartedAt = new Date().toISOString();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(job.data.payload ?? {}),
|
body: JSON.stringify(requestPayload ?? {}),
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,7 +167,7 @@ export function startOutboundDeliveryWorker() {
|
|||||||
channel: job.data.channel ?? null,
|
channel: job.data.channel ?? null,
|
||||||
provider: job.data.provider ?? null,
|
provider: job.data.provider ?? null,
|
||||||
startedAt: requestStartedAt,
|
startedAt: requestStartedAt,
|
||||||
payload: job.data.payload ?? null,
|
payload: requestPayload,
|
||||||
},
|
},
|
||||||
deliveryResponse: {
|
deliveryResponse: {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@@ -182,7 +197,7 @@ export function startOutboundDeliveryWorker() {
|
|||||||
channel: job.data.channel ?? null,
|
channel: job.data.channel ?? null,
|
||||||
provider: job.data.provider ?? null,
|
provider: job.data.provider ?? null,
|
||||||
startedAt: requestStartedAt,
|
startedAt: requestStartedAt,
|
||||||
payload: job.data.payload ?? null,
|
payload: requestPayload,
|
||||||
},
|
},
|
||||||
deliveryError: {
|
deliveryError: {
|
||||||
message: compactError(error),
|
message: compactError(error),
|
||||||
@@ -195,6 +210,6 @@ export function startOutboundDeliveryWorker() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ connection: getRedis() },
|
{ connection: redisConnectionFromEnv() },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user