feat: add scoped context payload and rollbackable document changes

This commit is contained in:
Ruslan Bakiev
2026-02-21 16:27:09 +07:00
parent 052f37d0ec
commit fa1231df37
5 changed files with 678 additions and 13 deletions

View File

@@ -337,6 +337,31 @@ type PilotMessage = {
};
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 = {
id: string;
@@ -357,6 +382,8 @@ const pilotMicError = ref<string | null>(null);
const pilotWaveContainer = ref<HTMLDivElement | null>(null);
const livePilotUserText = ref("");
const livePilotAssistantText = ref("");
const contextPickerEnabled = ref(false);
const contextScopes = ref<ContextScope[]>([]);
const pilotLiveLogs = ref<Array<{ id: string; text: string; at: string }>>([]);
const PILOT_LIVE_LOGS_PREVIEW_LIMIT = 5;
const pilotLiveLogsExpanded = ref(false);
@@ -374,6 +401,27 @@ const pilotVisibleLogCount = computed(() =>
function togglePilotLiveLogsExpanded() {
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 pilotRecorderStream: MediaStream | null = null;
let pilotRecordingChunks: Blob[] = [];
@@ -783,6 +831,7 @@ async function refreshCrmData() {
async function sendPilotText(rawText: string) {
const text = rawText.trim();
if (!text || pilotSending.value) return;
const contextPayload = buildContextPayload();
pilotSending.value = true;
pilotInput.value = "";
@@ -791,7 +840,16 @@ async function sendPilotText(rawText: string) {
pilotLiveLogsExpanded.value = false;
pilotLiveLogs.value = [];
try {
await pilotChat.sendMessage({ text });
await pilotChat.sendMessage(
{ text },
contextPayload
? {
body: {
contextPayload,
},
}
: undefined,
);
} catch {
pilotInput.value = text;
} finally {
@@ -2918,6 +2976,97 @@ const selectedWorkspaceDealSteps = computed(() => {
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(
() => selectedWorkspaceDeal.value?.id ?? "",
() => {
@@ -3564,6 +3713,17 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
<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
class="btn btn-xs btn-circle border border-white/20 bg-transparent text-white/90 hover:bg-white/10"
:class="pilotRecording ? 'pilot-mic-active' : ''"
@@ -3589,6 +3749,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</button>
</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>
</div>
</div>
@@ -3646,7 +3818,19 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
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'"
>
<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="flex items-center gap-1">
<button class="btn btn-xs" @click="setToday">Today</button>
@@ -4553,7 +4737,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</ul>
</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">
<textarea
v-model="commDraft"
@@ -4669,8 +4861,14 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<div
v-if="selectedWorkspaceDeal"
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">
{{ formatDealHeadline(selectedWorkspaceDeal) }}
</p>
@@ -4706,7 +4904,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</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>
<div
v-if="activeReviewContactDiff && selectedWorkspaceContact && activeReviewContactDiff.contactId === selectedWorkspaceContact.id"
@@ -5507,4 +5713,34 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
opacity: 0;
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>