From 23a4deba378cdf4b29d9e1216f0698bce06f7850 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Thu, 19 Feb 2026 13:51:18 +0700 Subject: [PATCH] Refine CRM chat UX and add DB-backed pin toggle --- Frontend/app.vue | 113 +++++++++++++----- Frontend/graphql/operations/dashboard.graphql | 1 + .../operations/toggle-contact-pin.graphql | 6 + Frontend/nuxt.config.ts | 3 +- Frontend/prisma/schema.prisma | 1 + Frontend/prisma/seed.mjs | 1 + Frontend/server/agent/langgraphCrmAgent.ts | 6 +- Frontend/server/dataset/exporter.ts | 2 +- Frontend/server/graphql/schema.ts | 50 ++++++++ Frontend/server/queues/outboundDelivery.ts | 35 ++++-- 10 files changed, 173 insertions(+), 45 deletions(-) create mode 100644 Frontend/graphql/operations/toggle-contact-pin.graphql diff --git a/Frontend/app.vue b/Frontend/app.vue index ae11d13..053021c 100644 --- a/Frontend/app.vue +++ b/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 selectChatConversationMutation from "./graphql/operations/select-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 rollbackLatestChangeSetMutation from "./graphql/operations/rollback-latest-change-set.graphql?raw"; import { Chat as AiChat } from "@ai-sdk/vue"; @@ -66,6 +67,7 @@ type CommItem = { kind: "message" | "call"; direction: "in" | "out"; text: string; + audioUrl?: string; duration?: string; transcript?: string[]; }; @@ -283,10 +285,7 @@ let pilotWaveRecordPlugin: any = null; let pilotWaveMicSession: { onDestroy: () => void; onEnd: () => void } | null = null; const commCallWaveHosts = new Map(); const commCallWaveSurfers = new Map(); -const CALL_AUDIO_SAMPLE_URLS = [ - "/audio-samples/national-road-9.m4a", - "/audio-samples/meeting-recording-2026-02-11.webm", -]; +const FALLBACK_CALL_AUDIO_URL = "/audio-samples/national-road-9.m4a"; const callTranscriptOpen = ref>({}); const callTranscriptLoading = ref>({}); const callTranscriptText = ref>({}); @@ -330,6 +329,7 @@ const chatSwitching = ref(false); const chatCreating = ref(false); const chatArchivingId = ref(""); const chatThreadPickerOpen = ref(false); +const commPinToggling = ref(false); const selectedChatId = ref(""); const loginPhone = ref(""); const loginPassword = ref(""); @@ -491,7 +491,7 @@ async function gqlFetch(query: string, variables?: Record>> 0; - } - return CALL_AUDIO_SAMPLE_URLS[hash % CALL_AUDIO_SAMPLE_URLS.length] ?? CALL_AUDIO_SAMPLE_URLS[0]; +function getCallAudioUrl(item?: CommItem) { + const direct = String(item?.audioUrl ?? "").trim(); + return direct || FALLBACK_CALL_AUDIO_URL; } 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"); if (!callItem) return; - const audioUrl = getCallAudioUrl(itemId); + const audioUrl = getCallAudioUrl(callItem); const { WaveSurfer } = await loadWaveSurferModules(); const durationSeconds = @@ -1393,7 +1389,7 @@ const groupedContacts = computed(() => { const map = new Map(); for (const contact of filteredContacts.value) { - const key = contact.name[0].toUpperCase(); + const key = (contact.name[0] ?? "#").toUpperCase(); if (!map.has(key)) { map.set(key, []); } @@ -1411,7 +1407,8 @@ watchEffect(() => { return; } 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)) { - 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() { selectedTab.value = "documents"; 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)) { - 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)}`; }); +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 threadContactId = (selectedCommThread.value?.id ?? "").trim(); if (threadContactId) { @@ -1880,6 +1925,11 @@ function formatDealStepMeta(step: DealStep) { return formatDealDeadline(parsed); } +function formatYearMonthFirst(item: { first?: CalendarEvent }) { + if (!item.first) return ""; + return `${formatDay(item.first.start)} · ${item.first.title}`; +} + const selectedWorkspaceDealDueDate = computed(() => { const deal = selectedWorkspaceDeal.value; if (!deal) return null; @@ -1933,7 +1983,7 @@ async function transcribeCallItem(item: CommItem) { if (callTranscriptLoading.value[itemId]) return; if (callTranscriptText.value[itemId]) return; - const audioUrl = getCallAudioUrl(itemId); + const audioUrl = getCallAudioUrl(item); if (!audioUrl) { callTranscriptError.value[itemId] = "Audio source is missing"; return; @@ -2803,18 +2853,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
-

{{ selectedContact.name }}

+

{{ selectedContact!.name }}

- {{ selectedContact.company }} · {{ selectedContact.location }}, {{ selectedContact.country }} + {{ selectedContact!.company }} · {{ selectedContact!.location }}, {{ selectedContact!.country }}

-

Last contact · {{ formatStamp(selectedContact.lastContactAt) }}

+

Last contact · {{ formatStamp(selectedContact!.lastContactAt) }}

@@ -2846,7 +2896,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")

Recent messages

- +
@@ -3171,11 +3221,14 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") - {{ latestPinnedLabel }} - {{ selectedCommPinnedStream.length }} + {{ selectedCommPinnedStream.length }} -
+

Pinned note

diff --git a/Frontend/graphql/operations/dashboard.graphql b/Frontend/graphql/operations/dashboard.graphql index f3ca024..1b5e020 100644 --- a/Frontend/graphql/operations/dashboard.graphql +++ b/Frontend/graphql/operations/dashboard.graphql @@ -20,6 +20,7 @@ query DashboardQuery { kind direction text + audioUrl duration transcript } diff --git a/Frontend/graphql/operations/toggle-contact-pin.graphql b/Frontend/graphql/operations/toggle-contact-pin.graphql new file mode 100644 index 0000000..df2f53d --- /dev/null +++ b/Frontend/graphql/operations/toggle-contact-pin.graphql @@ -0,0 +1,6 @@ +mutation ToggleContactPinMutation($contact: String!, $text: String!) { + toggleContactPin(contact: $contact, text: $text) { + ok + pinned + } +} diff --git a/Frontend/nuxt.config.ts b/Frontend/nuxt.config.ts index d75264b..e7429bb 100644 --- a/Frontend/nuxt.config.ts +++ b/Frontend/nuxt.config.ts @@ -5,7 +5,6 @@ export default defineNuxtConfig({ devtools: { enabled: true }, css: ["~/assets/css/main.css"], vite: { - plugins: [tailwindcss()], + plugins: [tailwindcss() as any], }, }); - diff --git a/Frontend/prisma/schema.prisma b/Frontend/prisma/schema.prisma index 9f9be8d..794ac35 100644 --- a/Frontend/prisma/schema.prisma +++ b/Frontend/prisma/schema.prisma @@ -154,6 +154,7 @@ model ContactMessage { direction MessageDirection channel MessageChannel content String + audioUrl String? durationSec Int? transcriptJson Json? occurredAt DateTime @default(now()) diff --git a/Frontend/prisma/seed.mjs b/Frontend/prisma/seed.mjs index b786ca2..0a15f6e 100644 --- a/Frontend/prisma/seed.mjs +++ b/Frontend/prisma/seed.mjs @@ -209,6 +209,7 @@ async function main() { direction: "OUT", channel: "PHONE", content: "Созвон по уточнению: модули Odoo, потоки данных и AI-сценарии", + audioUrl: "/audio-samples/national-road-9.m4a", durationSec: 180 + ((i * 23) % 420), transcriptJson: [ `${contact.name}: Нам нужен поэтапный запуск, начнём с продаж и склада.`, diff --git a/Frontend/server/agent/langgraphCrmAgent.ts b/Frontend/server/agent/langgraphCrmAgent.ts index bb588f7..51489ea 100644 --- a/Frontend/server/agent/langgraphCrmAgent.ts +++ b/Frontend/server/agent/langgraphCrmAgent.ts @@ -7,6 +7,7 @@ import { ChatOpenAI } from "@langchain/openai"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; import { getLangfuseClient } from "../utils/langfuse"; +import { Prisma } from "@prisma/client"; function iso(d: Date) { return d.toISOString(); @@ -570,7 +571,7 @@ export async function runLangGraphCrmAgentFor(input: { channel: toChannel(change.channel), content: change.text, 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), }, }); @@ -598,7 +599,8 @@ export async function runLangGraphCrmAgentFor(input: { }; const crmTool = tool( - async (raw: z.infer) => { + async (rawInput: unknown) => { + const raw = CrmToolSchema.parse(rawInput); const toolName = `crm:${raw.action}`; const startedAt = new Date().toISOString(); toolsUsed.push(toolName); diff --git a/Frontend/server/dataset/exporter.ts b/Frontend/server/dataset/exporter.ts index 2535ca2..64ec8e5 100644 --- a/Frontend/server/dataset/exporter.ts +++ b/Frontend/server/dataset/exporter.ts @@ -120,7 +120,7 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId ); 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; contactIndex.push({ diff --git a/Frontend/server/graphql/schema.ts b/Frontend/server/graphql/schema.ts index 9ae4cc6..7016089 100644 --- a/Frontend/server/graphql/schema.ts +++ b/Frontend/server/graphql/schema.ts @@ -388,6 +388,7 @@ async function getDashboard(auth: AuthContext | null) { kind: m.kind === "CALL" ? "call" : "message", direction: m.direction === "IN" ? "in" : "out", text: m.content, + audioUrl: m.audioUrl ?? "", duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "", 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"; direction?: "in" | "out"; text?: string; + audioUrl?: string; at?: string; durationSec?: number; transcript?: string[]; @@ -540,6 +542,7 @@ async function createCommunication(auth: AuthContext | null, input: { direction: input?.direction === "in" ? "IN" : "OUT", channel: toDbChannel(input?.channel ?? "Phone") as any, content: (input?.text ?? "").trim(), + audioUrl: (input?.audioUrl ?? "").trim() || null, durationSec: typeof input?.durationSec === "number" ? input.durationSec : null, transcriptJson: Array.isArray(input?.transcript) ? input.transcript : undefined, occurredAt, @@ -710,6 +713,41 @@ async function logPilotNote(auth: AuthContext | null, textInput: string) { 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(` type Query { me: MePayload! @@ -728,6 +766,7 @@ export const crmGraphqlSchema = buildSchema(` confirmLatestChangeSet: MutationResult! rollbackLatestChangeSet: MutationResult! logPilotNote(text: String!): MutationResult! + toggleContactPin(contact: String!, text: String!): PinToggleResult! createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent! createCommunication(input: CreateCommunicationInput!): MutationWithIdResult! updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult! @@ -742,6 +781,11 @@ export const crmGraphqlSchema = buildSchema(` id: ID! } + type PinToggleResult { + ok: Boolean! + pinned: Boolean! + } + input CreateCalendarEventInput { title: String! start: String! @@ -757,6 +801,7 @@ export const crmGraphqlSchema = buildSchema(` kind: String direction: String text: String + audioUrl: String at: String durationSec: Int transcript: [String!] @@ -853,6 +898,7 @@ export const crmGraphqlSchema = buildSchema(` kind: String! direction: String! text: String! + audioUrl: String! duration: String! transcript: [String!]! } @@ -958,6 +1004,9 @@ export const crmGraphqlRoot = { logPilotNote: async (args: { text: string }, context: GraphQLContext) => 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(context.auth, args.input), @@ -969,6 +1018,7 @@ export const crmGraphqlRoot = { kind?: "message" | "call"; direction?: "in" | "out"; text?: string; + audioUrl?: string; at?: string; durationSec?: number; transcript?: string[]; diff --git a/Frontend/server/queues/outboundDelivery.ts b/Frontend/server/queues/outboundDelivery.ts index 35da29a..4fbe333 100644 --- a/Frontend/server/queues/outboundDelivery.ts +++ b/Frontend/server/queues/outboundDelivery.ts @@ -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 { getRedis } from "../utils/redis"; export const OUTBOUND_DELIVERY_QUEUE_NAME = "omni-outbound"; @@ -15,6 +15,19 @@ export type OutboundDeliveryJob = { 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"); @@ -47,8 +60,8 @@ function extractProviderMessageId(body: unknown): string | null { } export function outboundDeliveryQueue() { - return new Queue(OUTBOUND_DELIVERY_QUEUE_NAME, { - connection: getRedis(), + return new Queue(OUTBOUND_DELIVERY_QUEUE_NAME, { + connection: redisConnectionFromEnv(), defaultJobOptions: { removeOnComplete: { count: 1000 }, removeOnFail: { count: 5000 }, @@ -60,6 +73,7 @@ export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?: 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 }, @@ -75,7 +89,7 @@ export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?: method: input.method ?? "POST", channel: input.channel ?? null, provider: input.provider ?? null, - payload: input.payload, + payload, }, }, }, @@ -90,7 +104,7 @@ export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?: } export function startOutboundDeliveryWorker() { - return new Worker( + return new Worker( OUTBOUND_DELIVERY_QUEUE_NAME, async (job) => { const msg = await prisma.omniMessage.findUnique({ @@ -112,12 +126,13 @@ export function startOutboundDeliveryWorker() { ...(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(job.data.payload ?? {}), + body: JSON.stringify(requestPayload ?? {}), signal: AbortSignal.timeout(timeoutMs), }); @@ -152,7 +167,7 @@ export function startOutboundDeliveryWorker() { channel: job.data.channel ?? null, provider: job.data.provider ?? null, startedAt: requestStartedAt, - payload: job.data.payload ?? null, + payload: requestPayload, }, deliveryResponse: { status: response.status, @@ -182,7 +197,7 @@ export function startOutboundDeliveryWorker() { channel: job.data.channel ?? null, provider: job.data.provider ?? null, startedAt: requestStartedAt, - payload: job.data.payload ?? null, + payload: requestPayload, }, deliveryError: { message: compactError(error), @@ -195,6 +210,6 @@ export function startOutboundDeliveryWorker() { throw error; } }, - { connection: getRedis() }, + { connection: redisConnectionFromEnv() }, ); }