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 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>
|
||||
|
||||
Reference in New Issue
Block a user