feat: add scoped context payload and rollbackable document changes
This commit is contained in:
246
frontend/app.vue
246
frontend/app.vue
@@ -337,6 +337,31 @@ type PilotMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PilotChangeItem = NonNullable<PilotMessage["changeItems"]>[number];
|
type PilotChangeItem = NonNullable<PilotMessage["changeItems"]>[number];
|
||||||
|
type ContextScope = "summary" | "deal" | "message" | "calendar";
|
||||||
|
type PilotContextPayload = {
|
||||||
|
scopes: ContextScope[];
|
||||||
|
summary?: {
|
||||||
|
contactId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
deal?: {
|
||||||
|
dealId: string;
|
||||||
|
title: string;
|
||||||
|
contact: string;
|
||||||
|
};
|
||||||
|
message?: {
|
||||||
|
contactId?: string;
|
||||||
|
contact?: string;
|
||||||
|
intent: "add_message_or_reminder";
|
||||||
|
};
|
||||||
|
calendar?: {
|
||||||
|
view: CalendarView;
|
||||||
|
period: string;
|
||||||
|
selectedDateKey: string;
|
||||||
|
focusedEventId?: string;
|
||||||
|
eventIds: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type ChatConversation = {
|
type ChatConversation = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -357,6 +382,8 @@ const pilotMicError = ref<string | null>(null);
|
|||||||
const pilotWaveContainer = ref<HTMLDivElement | null>(null);
|
const pilotWaveContainer = ref<HTMLDivElement | null>(null);
|
||||||
const livePilotUserText = ref("");
|
const livePilotUserText = ref("");
|
||||||
const livePilotAssistantText = ref("");
|
const livePilotAssistantText = ref("");
|
||||||
|
const contextPickerEnabled = ref(false);
|
||||||
|
const contextScopes = ref<ContextScope[]>([]);
|
||||||
const pilotLiveLogs = ref<Array<{ id: string; text: string; at: string }>>([]);
|
const pilotLiveLogs = ref<Array<{ id: string; text: string; at: string }>>([]);
|
||||||
const PILOT_LIVE_LOGS_PREVIEW_LIMIT = 5;
|
const PILOT_LIVE_LOGS_PREVIEW_LIMIT = 5;
|
||||||
const pilotLiveLogsExpanded = ref(false);
|
const pilotLiveLogsExpanded = ref(false);
|
||||||
@@ -374,6 +401,27 @@ const pilotVisibleLogCount = computed(() =>
|
|||||||
function togglePilotLiveLogsExpanded() {
|
function togglePilotLiveLogsExpanded() {
|
||||||
pilotLiveLogsExpanded.value = !pilotLiveLogsExpanded.value;
|
pilotLiveLogsExpanded.value = !pilotLiveLogsExpanded.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleContextPicker() {
|
||||||
|
contextPickerEnabled.value = !contextPickerEnabled.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasContextScope(scope: ContextScope) {
|
||||||
|
return contextScopes.value.includes(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleContextScope(scope: ContextScope) {
|
||||||
|
if (!contextPickerEnabled.value) return;
|
||||||
|
if (hasContextScope(scope)) {
|
||||||
|
contextScopes.value = contextScopes.value.filter((item) => item !== scope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contextScopes.value = [...contextScopes.value, scope];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeContextScope(scope: ContextScope) {
|
||||||
|
contextScopes.value = contextScopes.value.filter((item) => item !== scope);
|
||||||
|
}
|
||||||
let pilotMediaRecorder: MediaRecorder | null = null;
|
let pilotMediaRecorder: MediaRecorder | null = null;
|
||||||
let pilotRecorderStream: MediaStream | null = null;
|
let pilotRecorderStream: MediaStream | null = null;
|
||||||
let pilotRecordingChunks: Blob[] = [];
|
let pilotRecordingChunks: Blob[] = [];
|
||||||
@@ -783,6 +831,7 @@ async function refreshCrmData() {
|
|||||||
async function sendPilotText(rawText: string) {
|
async function sendPilotText(rawText: string) {
|
||||||
const text = rawText.trim();
|
const text = rawText.trim();
|
||||||
if (!text || pilotSending.value) return;
|
if (!text || pilotSending.value) return;
|
||||||
|
const contextPayload = buildContextPayload();
|
||||||
|
|
||||||
pilotSending.value = true;
|
pilotSending.value = true;
|
||||||
pilotInput.value = "";
|
pilotInput.value = "";
|
||||||
@@ -791,7 +840,16 @@ async function sendPilotText(rawText: string) {
|
|||||||
pilotLiveLogsExpanded.value = false;
|
pilotLiveLogsExpanded.value = false;
|
||||||
pilotLiveLogs.value = [];
|
pilotLiveLogs.value = [];
|
||||||
try {
|
try {
|
||||||
await pilotChat.sendMessage({ text });
|
await pilotChat.sendMessage(
|
||||||
|
{ text },
|
||||||
|
contextPayload
|
||||||
|
? {
|
||||||
|
body: {
|
||||||
|
contextPayload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
pilotInput.value = text;
|
pilotInput.value = text;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2918,6 +2976,97 @@ const selectedWorkspaceDealSteps = computed(() => {
|
|||||||
return [...deal.steps].sort((a, b) => a.order - b.order);
|
return [...deal.steps].sort((a, b) => a.order - b.order);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function calendarScopeLabel() {
|
||||||
|
if (focusedCalendarEvent.value) {
|
||||||
|
return `Календарь: ${focusedCalendarEvent.value.title}`;
|
||||||
|
}
|
||||||
|
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||||||
|
return `Календарь: ${monthLabel.value}`;
|
||||||
|
}
|
||||||
|
if (calendarView.value === "year") {
|
||||||
|
return `Календарь: ${calendarCursor.value.getFullYear()}`;
|
||||||
|
}
|
||||||
|
if (calendarView.value === "week") {
|
||||||
|
return `Календарь: ${calendarPeriodLabel.value}`;
|
||||||
|
}
|
||||||
|
return `Календарь: ${formatDay(`${selectedDateKey.value}T00:00:00`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextScopeLabel(scope: ContextScope) {
|
||||||
|
if (scope === "summary") return "Summary";
|
||||||
|
if (scope === "deal") return "Сделка";
|
||||||
|
if (scope === "message") return "Работа с пользователем";
|
||||||
|
return calendarScopeLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextScopeChips = computed(() =>
|
||||||
|
contextScopes.value.map((scope) => ({
|
||||||
|
scope,
|
||||||
|
label: contextScopeLabel(scope),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
function buildContextPayload(): PilotContextPayload | null {
|
||||||
|
const scopes = [...contextScopes.value];
|
||||||
|
if (!scopes.length) return null;
|
||||||
|
|
||||||
|
const payload: PilotContextPayload = { scopes };
|
||||||
|
|
||||||
|
if (hasContextScope("summary") && selectedWorkspaceContact.value) {
|
||||||
|
payload.summary = {
|
||||||
|
contactId: selectedWorkspaceContact.value.id,
|
||||||
|
name: selectedWorkspaceContact.value.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasContextScope("deal") && selectedWorkspaceDeal.value) {
|
||||||
|
payload.deal = {
|
||||||
|
dealId: selectedWorkspaceDeal.value.id,
|
||||||
|
title: selectedWorkspaceDeal.value.title,
|
||||||
|
contact: selectedWorkspaceDeal.value.contact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasContextScope("message")) {
|
||||||
|
payload.message = {
|
||||||
|
contactId: selectedWorkspaceContact.value?.id || undefined,
|
||||||
|
contact: selectedWorkspaceContact.value?.name || selectedCommThread.value?.contact || undefined,
|
||||||
|
intent: "add_message_or_reminder",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasContextScope("calendar")) {
|
||||||
|
const eventIds = (() => {
|
||||||
|
if (focusedCalendarEvent.value) return [focusedCalendarEvent.value.id];
|
||||||
|
if (calendarView.value === "day") return selectedDayEvents.value.map((event) => event.id);
|
||||||
|
if (calendarView.value === "week") return weekDays.value.flatMap((d) => d.events.map((event) => event.id));
|
||||||
|
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||||||
|
const monthStart = new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth(), 1);
|
||||||
|
const monthEnd = new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth() + 1, 1);
|
||||||
|
return sortedEvents.value
|
||||||
|
.filter((event) => {
|
||||||
|
const d = new Date(event.start);
|
||||||
|
return d >= monthStart && d < monthEnd;
|
||||||
|
})
|
||||||
|
.map((event) => event.id);
|
||||||
|
}
|
||||||
|
return sortedEvents.value
|
||||||
|
.filter((event) => new Date(event.start).getFullYear() === calendarCursor.value.getFullYear())
|
||||||
|
.map((event) => event.id);
|
||||||
|
})();
|
||||||
|
|
||||||
|
payload.calendar = {
|
||||||
|
view: calendarView.value,
|
||||||
|
period: calendarPeriodLabel.value,
|
||||||
|
selectedDateKey: selectedDateKey.value,
|
||||||
|
focusedEventId: focusedCalendarEvent.value?.id || undefined,
|
||||||
|
eventIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => selectedWorkspaceDeal.value?.id ?? "",
|
() => selectedWorkspaceDeal.value?.id ?? "",
|
||||||
() => {
|
() => {
|
||||||
@@ -3564,6 +3713,17 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pilot-input-actions">
|
<div class="pilot-input-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-circle border border-white/20 bg-transparent text-white/90 hover:bg-white/10"
|
||||||
|
:class="contextPickerEnabled ? 'pilot-mic-active' : ''"
|
||||||
|
:disabled="pilotTranscribing || pilotSending"
|
||||||
|
:title="contextPickerEnabled ? 'Выключить пипетку' : 'Включить пипетку контекста'"
|
||||||
|
@click="toggleContextPicker"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||||||
|
<path d="M19.29 4.71a1 1 0 0 0-1.42 0l-2.58 2.58-1.17-1.17a1 1 0 0 0-1.41 0l-1.42 1.42a1 1 0 0 0 0 1.41l.59.59-5.3 5.3a2 2 0 0 0-.53.92l-.86 3.43a1 1 0 0 0 1.21 1.21l3.43-.86a2 2 0 0 0 .92-.53l5.3-5.3.59.59a1 1 0 0 0 1.41 0l1.42-1.42a1 1 0 0 0 0-1.41l-1.17-1.17 2.58-2.58a1 1 0 0 0 0-1.42z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-xs btn-circle border border-white/20 bg-transparent text-white/90 hover:bg-white/10"
|
class="btn btn-xs btn-circle border border-white/20 bg-transparent text-white/90 hover:bg-white/10"
|
||||||
:class="pilotRecording ? 'pilot-mic-active' : ''"
|
:class="pilotRecording ? 'pilot-mic-active' : ''"
|
||||||
@@ -3589,6 +3749,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="contextScopeChips.length" class="mt-1 flex flex-wrap items-center gap-1.5 px-1">
|
||||||
|
<button
|
||||||
|
v-for="chip in contextScopeChips"
|
||||||
|
:key="`context-chip-${chip.scope}`"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 rounded border border-white/25 bg-white/10 px-2 py-0.5 text-[10px] text-white/90 hover:bg-white/15"
|
||||||
|
@click="removeContextScope(chip.scope)"
|
||||||
|
>
|
||||||
|
{{ chip.label }}
|
||||||
|
<span class="opacity-70">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p v-if="pilotMicError" class="pilot-mic-error">{{ pilotMicError }}</p>
|
<p v-if="pilotMicError" class="pilot-mic-error">{{ pilotMicError }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3646,7 +3818,19 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
class="min-h-0 flex-1"
|
class="min-h-0 flex-1"
|
||||||
:class="selectedTab === 'communications' && peopleLeftMode === 'contacts' ? 'px-0 pt-0 pb-0' : 'px-3 pt-3 pb-0 md:px-4 md:pt-4 md:pb-0'"
|
:class="selectedTab === 'communications' && peopleLeftMode === 'contacts' ? 'px-0 pt-0 pb-0' : 'px-3 pt-3 pb-0 md:px-4 md:pt-4 md:pb-0'"
|
||||||
>
|
>
|
||||||
<section v-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'" class="flex h-full min-h-0 flex-col gap-3">
|
<section
|
||||||
|
v-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'"
|
||||||
|
class="relative flex h-full min-h-0 flex-col gap-3"
|
||||||
|
:class="[
|
||||||
|
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
|
||||||
|
hasContextScope('calendar') ? 'context-scope-block-selected' : '',
|
||||||
|
]"
|
||||||
|
@click="toggleContextScope('calendar')"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="contextPickerEnabled"
|
||||||
|
class="context-scope-label"
|
||||||
|
>{{ contextScopeLabel('calendar') }}</span>
|
||||||
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button class="btn btn-xs" @click="setToday">Today</button>
|
<button class="btn btn-xs" @click="setToday">Today</button>
|
||||||
@@ -4553,7 +4737,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comm-input-wrap">
|
<div
|
||||||
|
class="comm-input-wrap"
|
||||||
|
:class="[
|
||||||
|
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
|
||||||
|
hasContextScope('message') ? 'context-scope-block-selected' : '',
|
||||||
|
]"
|
||||||
|
@click="toggleContextScope('message')"
|
||||||
|
>
|
||||||
|
<span v-if="contextPickerEnabled" class="context-scope-label">Работа с пользователем</span>
|
||||||
<div class="comm-input-shell">
|
<div class="comm-input-shell">
|
||||||
<textarea
|
<textarea
|
||||||
v-model="commDraft"
|
v-model="commDraft"
|
||||||
@@ -4669,8 +4861,14 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
<div
|
<div
|
||||||
v-if="selectedWorkspaceDeal"
|
v-if="selectedWorkspaceDeal"
|
||||||
class="rounded-xl border border-base-300 bg-base-200/30 p-2.5"
|
class="rounded-xl border border-base-300 bg-base-200/30 p-2.5"
|
||||||
:class="isReviewHighlightedDeal(selectedWorkspaceDeal.id) ? 'border-primary/60 bg-primary/10' : ''"
|
:class="[
|
||||||
|
isReviewHighlightedDeal(selectedWorkspaceDeal.id) ? 'border-primary/60 bg-primary/10' : '',
|
||||||
|
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
|
||||||
|
hasContextScope('deal') ? 'context-scope-block-selected' : '',
|
||||||
|
]"
|
||||||
|
@click="toggleContextScope('deal')"
|
||||||
>
|
>
|
||||||
|
<span v-if="contextPickerEnabled" class="context-scope-label">Сделка</span>
|
||||||
<p class="text-sm font-medium">
|
<p class="text-sm font-medium">
|
||||||
{{ formatDealHeadline(selectedWorkspaceDeal) }}
|
{{ formatDealHeadline(selectedWorkspaceDeal) }}
|
||||||
</p>
|
</p>
|
||||||
@@ -4706,7 +4904,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div
|
||||||
|
class="relative"
|
||||||
|
:class="[
|
||||||
|
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
|
||||||
|
hasContextScope('summary') ? 'context-scope-block-selected' : '',
|
||||||
|
]"
|
||||||
|
@click="toggleContextScope('summary')"
|
||||||
|
>
|
||||||
|
<span v-if="contextPickerEnabled" class="context-scope-label">Summary</span>
|
||||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-base-content/60">Summary</p>
|
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-base-content/60">Summary</p>
|
||||||
<div
|
<div
|
||||||
v-if="activeReviewContactDiff && selectedWorkspaceContact && activeReviewContactDiff.contactId === selectedWorkspaceContact.id"
|
v-if="activeReviewContactDiff && selectedWorkspaceContact && activeReviewContactDiff.contactId === selectedWorkspaceContact.id"
|
||||||
@@ -5507,4 +5713,34 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-scope-block {
|
||||||
|
position: relative;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-scope-block-active {
|
||||||
|
outline: 2px dashed color-mix(in oklab, var(--color-primary) 55%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-scope-block-selected {
|
||||||
|
outline: 2px solid color-mix(in oklab, var(--color-primary) 75%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-scope-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 20;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-primary) 40%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--color-base-100) 86%, var(--color-primary));
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: color-mix(in oklab, var(--color-primary-content) 65%, var(--color-base-content));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -42,6 +42,31 @@ export type AgentTraceEvent = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PilotContextPayload = {
|
||||||
|
scopes: Array<"summary" | "deal" | "message" | "calendar">;
|
||||||
|
summary?: {
|
||||||
|
contactId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
deal?: {
|
||||||
|
dealId: string;
|
||||||
|
title: string;
|
||||||
|
contact: string;
|
||||||
|
};
|
||||||
|
message?: {
|
||||||
|
contactId?: string;
|
||||||
|
contact?: string;
|
||||||
|
intent: "add_message_or_reminder";
|
||||||
|
};
|
||||||
|
calendar?: {
|
||||||
|
view: "day" | "week" | "month" | "year" | "agenda";
|
||||||
|
period: string;
|
||||||
|
selectedDateKey: string;
|
||||||
|
focusedEventId?: string;
|
||||||
|
eventIds: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
function normalize(s: string) {
|
function normalize(s: string) {
|
||||||
return s.trim().toLowerCase();
|
return s.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -97,6 +122,7 @@ export async function runCrmAgentFor(
|
|||||||
teamId: string;
|
teamId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
userText: string;
|
userText: string;
|
||||||
|
contextPayload?: PilotContextPayload | null;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;
|
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { AgentReply, AgentTraceEvent } from "./crmAgent";
|
import type { AgentReply, AgentTraceEvent, PilotContextPayload } from "./crmAgent";
|
||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
import { ensureDataset } from "../dataset/exporter";
|
import { ensureDataset } from "../dataset/exporter";
|
||||||
import { createReactAgent } from "@langchain/langgraph/prebuilt";
|
import { createReactAgent } from "@langchain/langgraph/prebuilt";
|
||||||
@@ -315,6 +315,7 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
teamId: string;
|
teamId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
userText: string;
|
userText: string;
|
||||||
|
contextPayload?: PilotContextPayload | null;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;
|
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;
|
||||||
@@ -439,6 +440,10 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
"getUserCalendarWindow",
|
"getUserCalendarWindow",
|
||||||
"updateContactSummary",
|
"updateContactSummary",
|
||||||
"createUserCalendarEvent",
|
"createUserCalendarEvent",
|
||||||
|
"getWorkspaceDocumentsList",
|
||||||
|
"getWorkspaceDocument",
|
||||||
|
"updateWorkspaceDocument",
|
||||||
|
"createWorkspaceDocument",
|
||||||
]),
|
]),
|
||||||
// read actions
|
// read actions
|
||||||
query: z.string().optional(),
|
query: z.string().optional(),
|
||||||
@@ -460,12 +465,20 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
end: z.string().optional(),
|
end: z.string().optional(),
|
||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
archived: z.boolean().optional(),
|
archived: z.boolean().optional(),
|
||||||
|
// workspace document actions
|
||||||
|
documentId: z.string().optional(),
|
||||||
|
documentType: z.enum(["Regulation", "Playbook", "Policy", "Template"]).optional(),
|
||||||
|
owner: z.string().optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
body: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const readActionNames = new Set([
|
const readActionNames = new Set([
|
||||||
"getContactsList",
|
"getContactsList",
|
||||||
"getContactSnapshot",
|
"getContactSnapshot",
|
||||||
"getUserCalendarWindow",
|
"getUserCalendarWindow",
|
||||||
|
"getWorkspaceDocumentsList",
|
||||||
|
"getWorkspaceDocument",
|
||||||
]);
|
]);
|
||||||
const readToolCache = new Map<string, string>();
|
const readToolCache = new Map<string, string>();
|
||||||
const invalidateReadCache = () => {
|
const invalidateReadCache = () => {
|
||||||
@@ -854,6 +867,186 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
}, null, 2);
|
}, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw.action === "getWorkspaceDocumentsList") {
|
||||||
|
const q = (raw.query ?? "").trim();
|
||||||
|
const limit = Math.max(1, Math.min(raw.limit ?? 30, 200));
|
||||||
|
const offset = Math.max(0, raw.offset ?? 0);
|
||||||
|
const where = {
|
||||||
|
teamId: input.teamId,
|
||||||
|
...(raw.documentType ? { type: raw.documentType } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: q } },
|
||||||
|
{ owner: { contains: q } },
|
||||||
|
{ scope: { contains: q } },
|
||||||
|
{ summary: { contains: q } },
|
||||||
|
{ body: { contains: q } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
prisma.workspaceDocument.count({ where }),
|
||||||
|
prisma.workspaceDocument.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
owner: true,
|
||||||
|
scope: true,
|
||||||
|
summary: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return cacheReadResult(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
items: items.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
type: d.type,
|
||||||
|
owner: d.owner,
|
||||||
|
scope: d.scope,
|
||||||
|
summary: d.summary,
|
||||||
|
updatedAt: d.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
returned: items.length,
|
||||||
|
total,
|
||||||
|
hasMore: offset + items.length < total,
|
||||||
|
nextOffset: offset + items.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.action === "getWorkspaceDocument") {
|
||||||
|
const documentId = (raw.documentId ?? "").trim();
|
||||||
|
if (!documentId) throw new Error("documentId is required");
|
||||||
|
const doc = await prisma.workspaceDocument.findFirst({
|
||||||
|
where: { id: documentId, teamId: input.teamId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
owner: true,
|
||||||
|
scope: true,
|
||||||
|
summary: true,
|
||||||
|
body: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!doc) throw new Error("document not found");
|
||||||
|
|
||||||
|
return cacheReadResult(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
document: {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
type: doc.type,
|
||||||
|
owner: doc.owner,
|
||||||
|
scope: doc.scope,
|
||||||
|
summary: doc.summary,
|
||||||
|
body: doc.body,
|
||||||
|
createdAt: doc.createdAt.toISOString(),
|
||||||
|
updatedAt: doc.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.action === "updateWorkspaceDocument") {
|
||||||
|
const documentId = (raw.documentId ?? "").trim();
|
||||||
|
if (!documentId) throw new Error("documentId is required");
|
||||||
|
|
||||||
|
const existing = await prisma.workspaceDocument.findFirst({
|
||||||
|
where: { id: documentId, teamId: input.teamId },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new Error("document not found");
|
||||||
|
|
||||||
|
const title = raw.title?.trim();
|
||||||
|
const owner = raw.owner?.trim();
|
||||||
|
const scope = raw.scope?.trim();
|
||||||
|
const summary = raw.summary?.trim();
|
||||||
|
const body = raw.body?.trim();
|
||||||
|
|
||||||
|
if (!title && !owner && !scope && !summary && !body && !raw.documentType) {
|
||||||
|
throw new Error("at least one field is required to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.workspaceDocument.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
...(raw.documentType ? { type: raw.documentType } : {}),
|
||||||
|
...(owner ? { owner } : {}),
|
||||||
|
...(scope ? { scope } : {}),
|
||||||
|
...(summary ? { summary } : {}),
|
||||||
|
...(body ? { body } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
invalidateReadCache();
|
||||||
|
dbWrites.push({
|
||||||
|
kind: "updateWorkspaceDocument",
|
||||||
|
detail: `${existing.title}: document updated`,
|
||||||
|
});
|
||||||
|
return JSON.stringify({ ok: true, applied: 1, documentId: existing.id }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.action === "createWorkspaceDocument") {
|
||||||
|
const title = (raw.title ?? "").trim();
|
||||||
|
const documentType = raw.documentType;
|
||||||
|
const owner = (raw.owner ?? "").trim();
|
||||||
|
const scope = (raw.scope ?? "").trim();
|
||||||
|
const summary = (raw.summary ?? "").trim();
|
||||||
|
const body = (raw.body ?? raw.content ?? "").trim();
|
||||||
|
|
||||||
|
if (!title) throw new Error("title is required");
|
||||||
|
if (!documentType) throw new Error("documentType is required");
|
||||||
|
if (!owner) throw new Error("owner is required");
|
||||||
|
if (!scope) throw new Error("scope is required");
|
||||||
|
if (!summary) throw new Error("summary is required");
|
||||||
|
if (!body) throw new Error("body is required");
|
||||||
|
|
||||||
|
const created = await prisma.workspaceDocument.create({
|
||||||
|
data: {
|
||||||
|
teamId: input.teamId,
|
||||||
|
title,
|
||||||
|
type: documentType,
|
||||||
|
owner,
|
||||||
|
scope,
|
||||||
|
summary,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true },
|
||||||
|
});
|
||||||
|
invalidateReadCache();
|
||||||
|
dbWrites.push({
|
||||||
|
kind: "createWorkspaceDocument",
|
||||||
|
detail: `created document ${created.title}`,
|
||||||
|
});
|
||||||
|
return JSON.stringify({ ok: true, applied: 1, documentId: created.id }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
return JSON.stringify({ ok: false, error: "unknown action" });
|
return JSON.stringify({ ok: false, error: "unknown action" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -891,13 +1084,24 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
{
|
{
|
||||||
name: "crm",
|
name: "crm",
|
||||||
description:
|
description:
|
||||||
"CRM tool with exactly five actions: getContactsList, getContactSnapshot, getUserCalendarWindow, updateContactSummary, createUserCalendarEvent.",
|
"CRM tool with actions for contacts, calendar, and workspace documents.",
|
||||||
schema: CrmToolSchema,
|
schema: CrmToolSchema,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = await buildCrmSnapshot({ teamId: input.teamId });
|
const scopedContext = input.contextPayload ?? null;
|
||||||
|
const focusedContact =
|
||||||
|
scopedContext?.summary?.name ||
|
||||||
|
scopedContext?.deal?.contact ||
|
||||||
|
scopedContext?.message?.contact ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
const snapshot = await buildCrmSnapshot({
|
||||||
|
teamId: input.teamId,
|
||||||
|
...(focusedContact ? { contact: focusedContact } : {}),
|
||||||
|
});
|
||||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||||
|
const scopedContextJson = JSON.stringify(scopedContext, null, 2);
|
||||||
|
|
||||||
const model = new ChatOpenAI({
|
const model = new ChatOpenAI({
|
||||||
apiKey: llmApiKey,
|
apiKey: llmApiKey,
|
||||||
@@ -939,6 +1143,7 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
requestId: input.requestId ?? null,
|
requestId: input.requestId ?? null,
|
||||||
conversationId: input.conversationId ?? null,
|
conversationId: input.conversationId ?? null,
|
||||||
|
contextScopes: scopedContext?.scopes ?? [],
|
||||||
},
|
},
|
||||||
tags: ["clientsflow", "crm-agent", "langgraph"],
|
tags: ["clientsflow", "crm-agent", "langgraph"],
|
||||||
});
|
});
|
||||||
@@ -951,12 +1156,18 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
"- Be concrete and complete. Do not cut important details in the final answer.",
|
"- Be concrete and complete. Do not cut important details in the final answer.",
|
||||||
"- Work in short iterative cycles. Do not stop after the first thought if the task needs more than one action.",
|
"- Work in short iterative cycles. Do not stop after the first thought if the task needs more than one action.",
|
||||||
"- You are given a structured CRM JSON snapshot as baseline context.",
|
"- You are given a structured CRM JSON snapshot as baseline context.",
|
||||||
"- Only use these actions: crm.getContactsList, crm.getContactSnapshot, crm.getUserCalendarWindow, crm.updateContactSummary, crm.createUserCalendarEvent.",
|
"- If Scoped Context Payload JSON is provided, treat it as primary context from the UI selection.",
|
||||||
|
"- Prefer entities from scoped context and avoid unrelated entities unless explicitly asked.",
|
||||||
|
"- Only use these actions: crm.getContactsList, crm.getContactSnapshot, crm.getUserCalendarWindow, crm.updateContactSummary, crm.createUserCalendarEvent, crm.getWorkspaceDocumentsList, crm.getWorkspaceDocument, crm.updateWorkspaceDocument, crm.createWorkspaceDocument.",
|
||||||
"- Use crm.getContactsList first to choose contacts, then crm.getContactSnapshot for deep context, then crm.getUserCalendarWindow for schedule validation.",
|
"- Use crm.getContactsList first to choose contacts, then crm.getContactSnapshot for deep context, then crm.getUserCalendarWindow for schedule validation.",
|
||||||
|
"- For policy/regulation requests, first call crm.getWorkspaceDocumentsList, then crm.getWorkspaceDocument before drafting an answer or applying edits.",
|
||||||
"- Avoid repeating identical read calls with the same arguments.",
|
"- Avoid repeating identical read calls with the same arguments.",
|
||||||
"- Write actions are immediate DB changes. Do not mention staging or commit.",
|
"- Write actions are immediate DB changes. Do not mention staging or commit.",
|
||||||
"- Do not claim you sent an external message; you can only create CRM records.",
|
"- Do not claim you sent an external message; you can only create CRM records.",
|
||||||
"",
|
"",
|
||||||
|
"Scoped Context Payload JSON:",
|
||||||
|
scopedContextJson,
|
||||||
|
"",
|
||||||
"CRM Snapshot JSON:",
|
"CRM Snapshot JSON:",
|
||||||
snapshotJson,
|
snapshotJson,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getAuthContext } from "../utils/auth";
|
|||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
|
import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
|
||||||
import { persistChatMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent";
|
import { persistChatMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent";
|
||||||
|
import type { PilotContextPayload } from "../agent/crmAgent";
|
||||||
import type { ChangeSet } from "../utils/changeSet";
|
import type { ChangeSet } from "../utils/changeSet";
|
||||||
|
|
||||||
function extractMessageText(message: any): string {
|
function extractMessageText(message: any): string {
|
||||||
@@ -25,6 +26,69 @@ function getLastUserText(messages: any[]): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeContextPayload(raw: unknown): PilotContextPayload | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const item = raw as Record<string, any>;
|
||||||
|
const scopesRaw = Array.isArray(item.scopes) ? item.scopes : [];
|
||||||
|
const scopes = scopesRaw
|
||||||
|
.map((scope) => String(scope))
|
||||||
|
.filter((scope) => scope === "summary" || scope === "deal" || scope === "message" || scope === "calendar") as PilotContextPayload["scopes"];
|
||||||
|
if (!scopes.length) return null;
|
||||||
|
|
||||||
|
const payload: PilotContextPayload = { scopes };
|
||||||
|
|
||||||
|
if (item.summary && typeof item.summary === "object") {
|
||||||
|
const contactId = String(item.summary.contactId ?? "").trim();
|
||||||
|
const name = String(item.summary.name ?? "").trim();
|
||||||
|
if (contactId && name) payload.summary = { contactId, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.deal && typeof item.deal === "object") {
|
||||||
|
const dealId = String(item.deal.dealId ?? "").trim();
|
||||||
|
const title = String(item.deal.title ?? "").trim();
|
||||||
|
const contact = String(item.deal.contact ?? "").trim();
|
||||||
|
if (dealId && title && contact) payload.deal = { dealId, title, contact };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.message && typeof item.message === "object") {
|
||||||
|
const contactId = String(item.message.contactId ?? "").trim();
|
||||||
|
const contact = String(item.message.contact ?? "").trim();
|
||||||
|
const intent = String(item.message.intent ?? "").trim();
|
||||||
|
if (intent === "add_message_or_reminder") {
|
||||||
|
payload.message = {
|
||||||
|
...(contactId ? { contactId } : {}),
|
||||||
|
...(contact ? { contact } : {}),
|
||||||
|
intent: "add_message_or_reminder",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.calendar && typeof item.calendar === "object") {
|
||||||
|
const view = String(item.calendar.view ?? "").trim();
|
||||||
|
const period = String(item.calendar.period ?? "").trim();
|
||||||
|
const selectedDateKey = String(item.calendar.selectedDateKey ?? "").trim();
|
||||||
|
const focusedEventId = String(item.calendar.focusedEventId ?? "").trim();
|
||||||
|
const eventIds = Array.isArray(item.calendar.eventIds)
|
||||||
|
? item.calendar.eventIds.map((id: any) => String(id ?? "").trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (
|
||||||
|
(view === "day" || view === "week" || view === "month" || view === "year" || view === "agenda") &&
|
||||||
|
period &&
|
||||||
|
selectedDateKey
|
||||||
|
) {
|
||||||
|
payload.calendar = {
|
||||||
|
view,
|
||||||
|
period,
|
||||||
|
selectedDateKey,
|
||||||
|
...(focusedEventId ? { focusedEventId } : {}),
|
||||||
|
eventIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
function humanizeTraceText(trace: AgentTraceEvent): string {
|
function humanizeTraceText(trace: AgentTraceEvent): string {
|
||||||
if (trace.toolRun?.name) {
|
if (trace.toolRun?.name) {
|
||||||
return `Использую инструмент: ${trace.toolRun.name}`;
|
return `Использую инструмент: ${trace.toolRun.name}`;
|
||||||
@@ -62,9 +126,10 @@ function renderChangeSetSummary(changeSet: ChangeSet): string {
|
|||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const auth = await getAuthContext(event);
|
const auth = await getAuthContext(event);
|
||||||
const body = await readBody<{ messages?: any[] }>(event);
|
const body = await readBody<{ messages?: any[]; contextPayload?: unknown }>(event);
|
||||||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||||
const userText = getLastUserText(messages);
|
const userText = getLastUserText(messages);
|
||||||
|
const contextPayload = sanitizeContextPayload(body?.contextPayload);
|
||||||
|
|
||||||
if (!userText) {
|
if (!userText) {
|
||||||
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
|
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
|
||||||
@@ -94,6 +159,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
teamId: auth.teamId,
|
teamId: auth.teamId,
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
userText,
|
userText,
|
||||||
|
contextPayload,
|
||||||
requestId,
|
requestId,
|
||||||
conversationId: auth.conversationId,
|
conversationId: auth.conversationId,
|
||||||
onTrace: async (trace: AgentTraceEvent) => {
|
onTrace: async (trace: AgentTraceEvent) => {
|
||||||
|
|||||||
@@ -41,16 +41,28 @@ type DealSnapshotRow = {
|
|||||||
summary: string | null;
|
summary: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorkspaceDocumentSnapshotRow = {
|
||||||
|
id: string;
|
||||||
|
teamId: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
owner: string;
|
||||||
|
scope: string;
|
||||||
|
summary: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SnapshotState = {
|
export type SnapshotState = {
|
||||||
calendarById: Map<string, CalendarSnapshotRow>;
|
calendarById: Map<string, CalendarSnapshotRow>;
|
||||||
noteByContactId: Map<string, ContactNoteSnapshotRow>;
|
noteByContactId: Map<string, ContactNoteSnapshotRow>;
|
||||||
messageById: Map<string, MessageSnapshotRow>;
|
messageById: Map<string, MessageSnapshotRow>;
|
||||||
dealById: Map<string, DealSnapshotRow>;
|
dealById: Map<string, DealSnapshotRow>;
|
||||||
|
documentById: Map<string, WorkspaceDocumentSnapshotRow>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChangeItem = {
|
export type ChangeItem = {
|
||||||
id: string;
|
id: string;
|
||||||
entity: "calendar_event" | "contact_note" | "message" | "deal";
|
entity: "calendar_event" | "contact_note" | "message" | "deal" | "workspace_document";
|
||||||
entityId: string | null;
|
entityId: string | null;
|
||||||
action: "created" | "updated" | "deleted";
|
action: "created" | "updated" | "deleted";
|
||||||
title: string;
|
title: string;
|
||||||
@@ -65,7 +77,9 @@ type UndoOp =
|
|||||||
| { kind: "delete_contact_message"; id: string }
|
| { kind: "delete_contact_message"; id: string }
|
||||||
| { kind: "restore_contact_message"; data: MessageSnapshotRow }
|
| { kind: "restore_contact_message"; data: MessageSnapshotRow }
|
||||||
| { kind: "restore_contact_note"; contactId: string; content: string | null }
|
| { kind: "restore_contact_note"; contactId: string; content: string | null }
|
||||||
| { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null };
|
| { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null }
|
||||||
|
| { kind: "delete_workspace_document"; id: string }
|
||||||
|
| { kind: "restore_workspace_document"; data: WorkspaceDocumentSnapshotRow };
|
||||||
|
|
||||||
export type ChangeSet = {
|
export type ChangeSet = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -95,8 +109,12 @@ function toDealText(row: DealSnapshotRow) {
|
|||||||
return `${row.title} (${row.contactName}) · ${row.stage}${row.nextStep ? ` · next: ${row.nextStep}` : ""}`;
|
return `${row.title} (${row.contactName}) · ${row.stage}${row.nextStep ? ` · next: ${row.nextStep}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toWorkspaceDocumentText(row: WorkspaceDocumentSnapshotRow) {
|
||||||
|
return `${row.title} · ${row.type} · ${row.owner} · ${row.scope} · ${row.summary}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise<SnapshotState> {
|
export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise<SnapshotState> {
|
||||||
const [calendar, notes, messages, deals] = await Promise.all([
|
const [calendar, notes, messages, deals, documents] = await Promise.all([
|
||||||
prisma.calendarEvent.findMany({
|
prisma.calendarEvent.findMany({
|
||||||
where: { teamId },
|
where: { teamId },
|
||||||
select: {
|
select: {
|
||||||
@@ -129,6 +147,20 @@ export async function captureSnapshot(prisma: PrismaClient, teamId: string): Pro
|
|||||||
include: { contact: { select: { name: true } } },
|
include: { contact: { select: { name: true } } },
|
||||||
take: 4000,
|
take: 4000,
|
||||||
}),
|
}),
|
||||||
|
prisma.workspaceDocument.findMany({
|
||||||
|
where: { teamId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
teamId: true,
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
owner: true,
|
||||||
|
scope: true,
|
||||||
|
summary: true,
|
||||||
|
body: true,
|
||||||
|
},
|
||||||
|
take: 4000,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -188,6 +220,21 @@ export async function captureSnapshot(prisma: PrismaClient, teamId: string): Pro
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
documentById: new Map(
|
||||||
|
documents.map((row) => [
|
||||||
|
row.id,
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
teamId: row.teamId,
|
||||||
|
title: row.title,
|
||||||
|
type: row.type,
|
||||||
|
owner: row.owner,
|
||||||
|
scope: row.scope,
|
||||||
|
summary: row.summary,
|
||||||
|
body: row.body,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +373,53 @@ export function buildChangeSet(before: SnapshotState, after: SnapshotState): Cha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [id, row] of after.documentById) {
|
||||||
|
const prev = before.documentById.get(id);
|
||||||
|
if (!prev) {
|
||||||
|
pushItem({
|
||||||
|
entity: "workspace_document",
|
||||||
|
entityId: row.id,
|
||||||
|
action: "created",
|
||||||
|
title: `Document created: ${row.title}`,
|
||||||
|
before: "",
|
||||||
|
after: toWorkspaceDocumentText(row),
|
||||||
|
undo: [{ kind: "delete_workspace_document", id }],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
prev.title !== row.title ||
|
||||||
|
prev.type !== row.type ||
|
||||||
|
prev.owner !== row.owner ||
|
||||||
|
prev.scope !== row.scope ||
|
||||||
|
prev.summary !== row.summary ||
|
||||||
|
prev.body !== row.body
|
||||||
|
) {
|
||||||
|
pushItem({
|
||||||
|
entity: "workspace_document",
|
||||||
|
entityId: row.id,
|
||||||
|
action: "updated",
|
||||||
|
title: `Document updated: ${row.title}`,
|
||||||
|
before: toWorkspaceDocumentText(prev),
|
||||||
|
after: toWorkspaceDocumentText(row),
|
||||||
|
undo: [{ kind: "restore_workspace_document", data: prev }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, row] of before.documentById) {
|
||||||
|
if (after.documentById.has(id)) continue;
|
||||||
|
pushItem({
|
||||||
|
entity: "workspace_document",
|
||||||
|
entityId: row.id,
|
||||||
|
action: "deleted",
|
||||||
|
title: `Document deleted: ${row.title}`,
|
||||||
|
before: toWorkspaceDocumentText(row),
|
||||||
|
after: "",
|
||||||
|
undo: [{ kind: "restore_workspace_document", data: row }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
const created = items.filter((x) => x.action === "created").length;
|
const created = items.filter((x) => x.action === "created").length;
|
||||||
@@ -440,6 +534,38 @@ async function applyUndoOps(prisma: PrismaClient, teamId: string, undoOps: UndoO
|
|||||||
summary: op.summary,
|
summary: op.summary,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op.kind === "delete_workspace_document") {
|
||||||
|
await tx.workspaceDocument.deleteMany({ where: { id: op.id, teamId } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op.kind === "restore_workspace_document") {
|
||||||
|
const row = op.data;
|
||||||
|
await tx.workspaceDocument.upsert({
|
||||||
|
where: { id: row.id },
|
||||||
|
update: {
|
||||||
|
teamId: row.teamId,
|
||||||
|
title: row.title,
|
||||||
|
type: row.type as any,
|
||||||
|
owner: row.owner,
|
||||||
|
scope: row.scope,
|
||||||
|
summary: row.summary,
|
||||||
|
body: row.body,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: row.id,
|
||||||
|
teamId: row.teamId,
|
||||||
|
title: row.title,
|
||||||
|
type: row.type as any,
|
||||||
|
owner: row.owner,
|
||||||
|
scope: row.scope,
|
||||||
|
summary: row.summary,
|
||||||
|
body: row.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user