diff --git a/Frontend/app.vue b/Frontend/app.vue index cdf8af1..ad317cf 100644 --- a/Frontend/app.vue +++ b/Frontend/app.vue @@ -303,6 +303,7 @@ type PilotMessage = { id: string; role: "user" | "assistant" | "system"; text: string; + messageKind?: string | null; requestId?: string | null; eventType?: string | null; phase?: string | null; @@ -434,6 +435,27 @@ function pilotRoleBadge(role: PilotMessage["role"]) { return "AI"; } +function summarizeChangeActions(items: PilotMessage["changeItems"] | null | undefined) { + const totals = { created: 0, updated: 0, deleted: 0 }; + for (const item of items ?? []) { + if (item.action === "created") totals.created += 1; + else if (item.action === "updated") totals.updated += 1; + else if (item.action === "deleted") totals.deleted += 1; + } + return totals; +} + +function summarizeChangeEntities(items: PilotMessage["changeItems"] | null | undefined) { + const map = new Map(); + for (const item of items ?? []) { + const key = item.entity || "unknown"; + map.set(key, (map.get(key) ?? 0) + 1); + } + return [...map.entries()] + .map(([entity, count]) => ({ entity, count })) + .sort((a, b) => b.count - a.count); +} + function formatPilotStamp(iso?: string) { if (!iso) return ""; return new Intl.DateTimeFormat("en-GB", { @@ -2699,7 +2721,50 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {{ formatPilotStamp(message.createdAt) }} -
+
+

+ {{ message.changeSummary || "Technical change summary" }} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
MetricValue
Total changes{{ message.changeItems?.length || 0 }}
Created{{ summarizeChangeActions(message.changeItems).created }}
Updated{{ summarizeChangeActions(message.changeItems).updated }}
Archived{{ summarizeChangeActions(message.changeItems).deleted }}
+
+
+ + {{ row.entity }}: {{ row.count }} + +
+
+ +
{{ message.text }}
diff --git a/Frontend/graphql/operations/chat-messages.graphql b/Frontend/graphql/operations/chat-messages.graphql index f439364..7799826 100644 --- a/Frontend/graphql/operations/chat-messages.graphql +++ b/Frontend/graphql/operations/chat-messages.graphql @@ -3,6 +3,7 @@ query ChatMessagesQuery { id role text + messageKind requestId eventType phase diff --git a/Frontend/server/agent/crmAgent.ts b/Frontend/server/agent/crmAgent.ts index 29cf9df..d61d785 100644 --- a/Frontend/server/agent/crmAgent.ts +++ b/Frontend/server/agent/crmAgent.ts @@ -247,11 +247,12 @@ export async function persistChatMessage(input: { eventType?: "user" | "trace" | "assistant" | "note"; phase?: "pending" | "running" | "final" | "error"; transient?: boolean; + messageKind?: "change_set_summary"; teamId: string; conversationId: string; authorUserId?: string | null; }) { - const hasStoredPayload = Boolean(input.changeSet); + const hasStoredPayload = Boolean(input.changeSet || input.messageKind); const data: Prisma.ChatMessageCreateInput = { team: { connect: { id: input.teamId } }, conversation: { connect: { id: input.conversationId } }, @@ -260,6 +261,7 @@ export async function persistChatMessage(input: { text: input.text, planJson: hasStoredPayload ? ({ + messageKind: input.messageKind ?? null, changeSet: input.changeSet ?? null, } as any) : undefined, diff --git a/Frontend/server/api/pilot-chat.post.ts b/Frontend/server/api/pilot-chat.post.ts index 4b2f380..c4eefbb 100644 --- a/Frontend/server/api/pilot-chat.post.ts +++ b/Frontend/server/api/pilot-chat.post.ts @@ -4,6 +4,7 @@ import { getAuthContext } from "../utils/auth"; import { prisma } from "../utils/prisma"; import { buildChangeSet, captureSnapshot } from "../utils/changeSet"; import { persistChatMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent"; +import type { ChangeSet } from "../utils/changeSet"; function extractMessageText(message: any): string { if (!message || !Array.isArray(message.parts)) return ""; @@ -37,6 +38,28 @@ function humanizeTraceText(trace: AgentTraceEvent): string { return text; } +function renderChangeSetSummary(changeSet: ChangeSet): string { + const totals = { created: 0, updated: 0, deleted: 0 }; + for (const item of changeSet.items) { + if (item.action === "created") totals.created += 1; + else if (item.action === "updated") totals.updated += 1; + else if (item.action === "deleted") totals.deleted += 1; + } + + const byEntity = new Map(); + for (const item of changeSet.items) { + byEntity.set(item.entity, (byEntity.get(item.entity) ?? 0) + 1); + } + + const lines = [ + "Technical change summary", + `Total: ${changeSet.items.length} · Created: ${totals.created} · Updated: ${totals.updated} · Archived: ${totals.deleted}`, + ...[...byEntity.entries()].map(([entity, count]) => `- ${entity}: ${count}`), + ]; + + return lines.join("\n"); +} + export default defineEventHandler(async (event) => { const auth = await getAuthContext(event); const body = await readBody<{ messages?: any[] }>(event); @@ -98,9 +121,24 @@ export default defineEventHandler(async (event) => { eventType: "assistant", phase: "final", transient: false, - changeSet, }); + if (changeSet) { + await persistChatMessage({ + teamId: auth.teamId, + conversationId: auth.conversationId, + authorUserId: null, + role: "ASSISTANT", + text: renderChangeSetSummary(changeSet), + requestId, + eventType: "note", + phase: "final", + transient: false, + messageKind: "change_set_summary", + changeSet, + }); + } + writer.write({ type: "text-start", id: textId }); writer.write({ type: "text-delta", id: textId, delta: reply.text }); writer.write({ type: "text-end", id: textId }); diff --git a/Frontend/server/graphql/schema.ts b/Frontend/server/graphql/schema.ts index fd39ecb..e7a7feb 100644 --- a/Frontend/server/graphql/schema.ts +++ b/Frontend/server/graphql/schema.ts @@ -258,10 +258,12 @@ async function getChatMessages(auth: AuthContext | null) { return items.map((m) => { const cs = getChangeSetFromPlanJson(m.planJson); + const messageKind = getMessageKindFromPlanJson(m.planJson) ?? (cs ? "change_set_summary" : null); return { id: m.id, role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system", text: m.text, + messageKind, requestId: null, eventType: null, phase: null, @@ -641,6 +643,27 @@ function getChangeSetFromPlanJson(planJson: unknown): ChangeSet | null { return cs as ChangeSet; } +function getMessageKindFromPlanJson(planJson: unknown): string | null { + const debug = (planJson as any) ?? {}; + const kind = debug?.messageKind; + if (!kind || typeof kind !== "string") return null; + return kind; +} + +function renderChangeSetSummary(changeSet: ChangeSet): string { + const totals = { created: 0, updated: 0, deleted: 0 }; + for (const item of changeSet.items) { + if (item.action === "created") totals.created += 1; + else if (item.action === "updated") totals.updated += 1; + else if (item.action === "deleted") totals.deleted += 1; + } + + return [ + "Technical change summary", + `Total: ${changeSet.items.length} · Created: ${totals.created} · Updated: ${totals.updated} · Archived: ${totals.deleted}`, + ].join("\n"); +} + async function findLatestChangeCarrierMessage(auth: AuthContext | null) { const ctx = requireAuth(auth); const items = await prisma.chatMessage.findMany({ @@ -755,9 +778,24 @@ async function sendPilotMessage(auth: AuthContext | null, textInput: string) { eventType: "assistant", phase: "final", transient: false, - changeSet, }); + if (changeSet) { + await persistChatMessage({ + teamId: ctx.teamId, + conversationId: ctx.conversationId, + authorUserId: null, + role: "ASSISTANT", + text: renderChangeSetSummary(changeSet), + requestId, + eventType: "note", + phase: "final", + transient: false, + messageKind: "change_set_summary", + changeSet, + }); + } + return { ok: true }; } @@ -909,6 +947,7 @@ export const crmGraphqlSchema = buildSchema(` id: ID! role: String! text: String! + messageKind: String requestId: String eventType: String phase: String