Add chat-side CRM diff panel with keep/rollback flow
This commit is contained in:
@@ -6,6 +6,8 @@ import { prisma } from "../utils/prisma";
|
||||
import { normalizePhone, verifyPassword } from "../utils/password";
|
||||
import { persistChatMessage, runCrmAgentFor } from "../agent/crmAgent";
|
||||
import type { AgentTraceEvent } from "../agent/crmAgent";
|
||||
import { buildChangeSet, captureSnapshot, rollbackChangeSet } from "../utils/changeSet";
|
||||
import type { ChangeSet } from "../utils/changeSet";
|
||||
|
||||
type GraphQLContext = {
|
||||
auth: AuthContext | null;
|
||||
@@ -218,6 +220,7 @@ async function getChatMessages(auth: AuthContext | null) {
|
||||
|
||||
return items.map((m) => {
|
||||
const debug = (m.planJson as any) ?? {};
|
||||
const cs = getChangeSetFromPlanJson(m.planJson);
|
||||
return {
|
||||
id: m.id,
|
||||
role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system",
|
||||
@@ -236,6 +239,18 @@ async function getChatMessages(auth: AuthContext | null) {
|
||||
at: t.at ? String(t.at) : m.createdAt.toISOString(),
|
||||
}))
|
||||
: [],
|
||||
changeSetId: cs?.id ?? null,
|
||||
changeStatus: cs?.status ?? null,
|
||||
changeSummary: cs?.summary ?? null,
|
||||
changeItems: Array.isArray(cs?.items)
|
||||
? cs.items.map((item) => ({
|
||||
entity: String(item.entity ?? ""),
|
||||
action: String(item.action ?? ""),
|
||||
title: String(item.title ?? ""),
|
||||
before: String(item.before ?? ""),
|
||||
after: String(item.after ?? ""),
|
||||
}))
|
||||
: [],
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
@@ -498,11 +513,93 @@ async function updateFeedDecision(auth: AuthContext | null, id: string, decision
|
||||
return { ok: true, id };
|
||||
}
|
||||
|
||||
function getChangeSetFromPlanJson(planJson: unknown): ChangeSet | null {
|
||||
const debug = (planJson as any) ?? {};
|
||||
const cs = debug?.changeSet;
|
||||
if (!cs || typeof cs !== "object") return null;
|
||||
if (!cs.id || !Array.isArray(cs.items) || !Array.isArray(cs.undo)) return null;
|
||||
return cs as ChangeSet;
|
||||
}
|
||||
|
||||
async function findLatestChangeCarrierMessage(auth: AuthContext | null) {
|
||||
const ctx = requireAuth(auth);
|
||||
const items = await prisma.chatMessage.findMany({
|
||||
where: {
|
||||
teamId: ctx.teamId,
|
||||
conversationId: ctx.conversationId,
|
||||
role: "ASSISTANT",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
const changeSet = getChangeSetFromPlanJson(item.planJson);
|
||||
if (!changeSet) continue;
|
||||
if (changeSet.status === "rolled_back") continue;
|
||||
return { item, changeSet };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function confirmLatestChangeSet(auth: AuthContext | null) {
|
||||
const found = await findLatestChangeCarrierMessage(auth);
|
||||
if (!found) return { ok: true };
|
||||
|
||||
const { item, changeSet } = found;
|
||||
if (changeSet.status === "confirmed") return { ok: true };
|
||||
|
||||
const next = {
|
||||
...((item.planJson as any) ?? {}),
|
||||
changeSet: {
|
||||
...changeSet,
|
||||
status: "confirmed",
|
||||
confirmedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.chatMessage.update({
|
||||
where: { id: item.id },
|
||||
data: { planJson: next as any },
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function rollbackLatestChangeSet(auth: AuthContext | null) {
|
||||
const ctx = requireAuth(auth);
|
||||
const found = await findLatestChangeCarrierMessage(ctx);
|
||||
if (!found) return { ok: true };
|
||||
|
||||
const { item, changeSet } = found;
|
||||
if (changeSet.status === "rolled_back") return { ok: true };
|
||||
|
||||
await rollbackChangeSet(prisma, ctx.teamId, changeSet);
|
||||
|
||||
const next = {
|
||||
...((item.planJson as any) ?? {}),
|
||||
changeSet: {
|
||||
...changeSet,
|
||||
status: "rolled_back",
|
||||
rolledBackAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.chatMessage.update({
|
||||
where: { id: item.id },
|
||||
data: { planJson: next as any },
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function sendPilotMessage(auth: AuthContext | null, textInput: string) {
|
||||
const ctx = requireAuth(auth);
|
||||
const text = (textInput ?? "").trim();
|
||||
if (!text) throw new Error("text is required");
|
||||
|
||||
const snapshotBefore = await captureSnapshot(prisma, ctx.teamId);
|
||||
|
||||
await persistChatMessage({
|
||||
teamId: ctx.teamId,
|
||||
conversationId: ctx.conversationId,
|
||||
@@ -529,6 +626,10 @@ async function sendPilotMessage(auth: AuthContext | null, textInput: string) {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const snapshotAfter = await captureSnapshot(prisma, ctx.teamId);
|
||||
const changeSet = buildChangeSet(snapshotBefore, snapshotAfter);
|
||||
|
||||
await persistChatMessage({
|
||||
teamId: ctx.teamId,
|
||||
conversationId: ctx.conversationId,
|
||||
@@ -539,6 +640,7 @@ async function sendPilotMessage(auth: AuthContext | null, textInput: string) {
|
||||
thinking: reply.thinking ?? [],
|
||||
tools: reply.tools,
|
||||
toolRuns: reply.toolRuns ?? [],
|
||||
changeSet,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
@@ -578,6 +680,8 @@ export const crmGraphqlSchema = buildSchema(`
|
||||
createChatConversation(title: String): Conversation!
|
||||
selectChatConversation(id: ID!): MutationResult!
|
||||
sendPilotMessage(text: String!): MutationResult!
|
||||
confirmLatestChangeSet: MutationResult!
|
||||
rollbackLatestChangeSet: MutationResult!
|
||||
logPilotNote(text: String!): MutationResult!
|
||||
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
|
||||
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
|
||||
@@ -647,9 +751,21 @@ export const crmGraphqlSchema = buildSchema(`
|
||||
thinking: [String!]!
|
||||
tools: [String!]!
|
||||
toolRuns: [PilotToolRun!]!
|
||||
changeSetId: String
|
||||
changeStatus: String
|
||||
changeSummary: String
|
||||
changeItems: [PilotChangeItem!]!
|
||||
createdAt: String!
|
||||
}
|
||||
|
||||
type PilotChangeItem {
|
||||
entity: String!
|
||||
action: String!
|
||||
title: String!
|
||||
before: String!
|
||||
after: String!
|
||||
}
|
||||
|
||||
type PilotToolRun {
|
||||
name: String!
|
||||
status: String!
|
||||
@@ -770,6 +886,12 @@ export const crmGraphqlRoot = {
|
||||
sendPilotMessage: async (args: { text: string }, context: GraphQLContext) =>
|
||||
sendPilotMessage(context.auth, args.text),
|
||||
|
||||
confirmLatestChangeSet: async (_args: unknown, context: GraphQLContext) =>
|
||||
confirmLatestChangeSet(context.auth),
|
||||
|
||||
rollbackLatestChangeSet: async (_args: unknown, context: GraphQLContext) =>
|
||||
rollbackLatestChangeSet(context.auth),
|
||||
|
||||
logPilotNote: async (args: { text: string }, context: GraphQLContext) =>
|
||||
logPilotNote(context.auth, args.text),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user