Refine CRM chat UX and add DB-backed pin toggle

This commit is contained in:
Ruslan Bakiev
2026-02-19 13:51:18 +07:00
parent 626d4ddd76
commit 23a4deba37
10 changed files with 173 additions and 45 deletions

View File

@@ -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<string, HTMLDivElement>();
const commCallWaveSurfers = new Map<string, any>();
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<Record<string, boolean>>({});
const callTranscriptLoading = ref<Record<string, boolean>>({});
const callTranscriptText = ref<Record<string, string>>({});
@@ -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<TData>(query: string, variables?: Record<string, unknown
});
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) {
@@ -739,13 +739,9 @@ function buildCallWavePeaks(item: CommItem, size = 320) {
return out;
}
function getCallAudioUrl(itemId: string) {
if (!CALL_AUDIO_SAMPLE_URLS.length) return "";
let hash = 0;
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];
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<string, Contact[]>();
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")
<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 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">
{{ selectedContact.company }} · {{ selectedContact.location }}, {{ selectedContact.country }}
{{ selectedContact!.company }} · {{ selectedContact!.location }}, {{ selectedContact!.country }}
</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 class="mt-3">
<ContactCollaborativeEditor
:key="`contact-editor-${selectedContact.id}`"
v-model="selectedContact.description"
:room="`crm-contact-${selectedContact.id}`"
:key="`contact-editor-${selectedContact!.id}`"
v-model="selectedContact!.description"
:room="`crm-contact-${selectedContact!.id}`"
placeholder="Describe contact context and next steps..."
/>
</div>
@@ -2846,7 +2896,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<section class="rounded-xl border border-base-300 p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<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 class="space-y-2">
<button
@@ -3142,7 +3192,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<p class="font-medium">{{ item.label }}</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">
{{ formatDay(item.first.start) }} · {{ item.first.title }}
{{ formatYearMonthFirst(item) }}
</p>
</button>
</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">
<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>
<span class="min-w-0 flex-1 truncate text-xs text-base-content/80">{{ latestPinnedLabel }}</span>
<span class="shrink-0 text-[11px] text-base-content/60">{{ selectedCommPinnedStream.length }}</span>
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
</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">
<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>

View File

@@ -20,6 +20,7 @@ query DashboardQuery {
kind
direction
text
audioUrl
duration
transcript
}

View File

@@ -0,0 +1,6 @@
mutation ToggleContactPinMutation($contact: String!, $text: String!) {
toggleContactPin(contact: $contact, text: $text) {
ok
pinned
}
}

View File

@@ -5,7 +5,6 @@ export default defineNuxtConfig({
devtools: { enabled: true },
css: ["~/assets/css/main.css"],
vite: {
plugins: [tailwindcss()],
plugins: [tailwindcss() as any],
},
});

View File

@@ -154,6 +154,7 @@ model ContactMessage {
direction MessageDirection
channel MessageChannel
content String
audioUrl String?
durationSec Int?
transcriptJson Json?
occurredAt DateTime @default(now())

View File

@@ -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}: Нам нужен поэтапный запуск, начнём с продаж и склада.`,

View File

@@ -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<typeof CrmToolSchema>) => {
async (rawInput: unknown) => {
const raw = CrmToolSchema.parse(rawInput);
const toolName = `crm:${raw.action}`;
const startedAt = new Date().toISOString();
toolsUsed.push(toolName);

View File

@@ -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({

View File

@@ -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[];

View File

@@ -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<OutboundDeliveryJob>(OUTBOUND_DELIVERY_QUEUE_NAME, {
connection: getRedis(),
return new Queue<OutboundDeliveryJob, unknown, "deliver">(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<OutboundDeliveryJob>(
return new Worker<OutboundDeliveryJob, unknown, "deliver">(
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() },
);
}