feat(chat): add typed change-set summary message in timeline
This commit is contained in:
@@ -303,6 +303,7 @@ type PilotMessage = {
|
|||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant" | "system";
|
role: "user" | "assistant" | "system";
|
||||||
text: string;
|
text: string;
|
||||||
|
messageKind?: string | null;
|
||||||
requestId?: string | null;
|
requestId?: string | null;
|
||||||
eventType?: string | null;
|
eventType?: string | null;
|
||||||
phase?: string | null;
|
phase?: string | null;
|
||||||
@@ -434,6 +435,27 @@ function pilotRoleBadge(role: PilotMessage["role"]) {
|
|||||||
return "AI";
|
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<string, number>();
|
||||||
|
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) {
|
function formatPilotStamp(iso?: string) {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
return new Intl.DateTimeFormat("en-GB", {
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
@@ -2699,7 +2721,50 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
<span class="pilot-time">{{ formatPilotStamp(message.createdAt) }}</span>
|
<span class="pilot-time">{{ formatPilotStamp(message.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pilot-message-text">
|
<div v-if="message.messageKind === 'change_set_summary'" class="rounded-xl border border-amber-300/35 bg-amber-500/10 p-3">
|
||||||
|
<p class="text-xs font-semibold text-amber-100">
|
||||||
|
{{ message.changeSummary || "Technical change summary" }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 overflow-x-auto">
|
||||||
|
<table class="w-full min-w-[340px] text-left text-[11px] text-white/85">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-white/60">
|
||||||
|
<th class="py-1 pr-2 font-medium">Metric</th>
|
||||||
|
<th class="py-1 pr-2 font-medium">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="py-1 pr-2">Total changes</td>
|
||||||
|
<td class="py-1 pr-2">{{ message.changeItems?.length || 0 }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-1 pr-2">Created</td>
|
||||||
|
<td class="py-1 pr-2">{{ summarizeChangeActions(message.changeItems).created }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-1 pr-2">Updated</td>
|
||||||
|
<td class="py-1 pr-2">{{ summarizeChangeActions(message.changeItems).updated }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-1 pr-2">Archived</td>
|
||||||
|
<td class="py-1 pr-2">{{ summarizeChangeActions(message.changeItems).deleted }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="summarizeChangeEntities(message.changeItems).length" class="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="row in summarizeChangeEntities(message.changeItems)"
|
||||||
|
:key="`entity-summary-${message.id}-${row.entity}`"
|
||||||
|
class="rounded border border-white/20 px-2 py-0.5 text-[10px] text-white/75"
|
||||||
|
>
|
||||||
|
{{ row.entity }}: {{ row.count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="pilot-message-text">
|
||||||
{{ message.text }}
|
{{ message.text }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ query ChatMessagesQuery {
|
|||||||
id
|
id
|
||||||
role
|
role
|
||||||
text
|
text
|
||||||
|
messageKind
|
||||||
requestId
|
requestId
|
||||||
eventType
|
eventType
|
||||||
phase
|
phase
|
||||||
|
|||||||
@@ -247,11 +247,12 @@ export async function persistChatMessage(input: {
|
|||||||
eventType?: "user" | "trace" | "assistant" | "note";
|
eventType?: "user" | "trace" | "assistant" | "note";
|
||||||
phase?: "pending" | "running" | "final" | "error";
|
phase?: "pending" | "running" | "final" | "error";
|
||||||
transient?: boolean;
|
transient?: boolean;
|
||||||
|
messageKind?: "change_set_summary";
|
||||||
teamId: string;
|
teamId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
authorUserId?: string | null;
|
authorUserId?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const hasStoredPayload = Boolean(input.changeSet);
|
const hasStoredPayload = Boolean(input.changeSet || input.messageKind);
|
||||||
const data: Prisma.ChatMessageCreateInput = {
|
const data: Prisma.ChatMessageCreateInput = {
|
||||||
team: { connect: { id: input.teamId } },
|
team: { connect: { id: input.teamId } },
|
||||||
conversation: { connect: { id: input.conversationId } },
|
conversation: { connect: { id: input.conversationId } },
|
||||||
@@ -260,6 +261,7 @@ export async function persistChatMessage(input: {
|
|||||||
text: input.text,
|
text: input.text,
|
||||||
planJson: hasStoredPayload
|
planJson: hasStoredPayload
|
||||||
? ({
|
? ({
|
||||||
|
messageKind: input.messageKind ?? null,
|
||||||
changeSet: input.changeSet ?? null,
|
changeSet: input.changeSet ?? null,
|
||||||
} as any)
|
} as any)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -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 { ChangeSet } from "../utils/changeSet";
|
||||||
|
|
||||||
function extractMessageText(message: any): string {
|
function extractMessageText(message: any): string {
|
||||||
if (!message || !Array.isArray(message.parts)) return "";
|
if (!message || !Array.isArray(message.parts)) return "";
|
||||||
@@ -37,6 +38,28 @@ function humanizeTraceText(trace: AgentTraceEvent): string {
|
|||||||
return text;
|
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<string, number>();
|
||||||
|
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) => {
|
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[] }>(event);
|
||||||
@@ -98,8 +121,23 @@ export default defineEventHandler(async (event) => {
|
|||||||
eventType: "assistant",
|
eventType: "assistant",
|
||||||
phase: "final",
|
phase: "final",
|
||||||
transient: false,
|
transient: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
changeSet,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
writer.write({ type: "text-start", id: textId });
|
writer.write({ type: "text-start", id: textId });
|
||||||
writer.write({ type: "text-delta", id: textId, delta: reply.text });
|
writer.write({ type: "text-delta", id: textId, delta: reply.text });
|
||||||
|
|||||||
@@ -258,10 +258,12 @@ async function getChatMessages(auth: AuthContext | null) {
|
|||||||
|
|
||||||
return items.map((m) => {
|
return items.map((m) => {
|
||||||
const cs = getChangeSetFromPlanJson(m.planJson);
|
const cs = getChangeSetFromPlanJson(m.planJson);
|
||||||
|
const messageKind = getMessageKindFromPlanJson(m.planJson) ?? (cs ? "change_set_summary" : null);
|
||||||
return {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system",
|
role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system",
|
||||||
text: m.text,
|
text: m.text,
|
||||||
|
messageKind,
|
||||||
requestId: null,
|
requestId: null,
|
||||||
eventType: null,
|
eventType: null,
|
||||||
phase: null,
|
phase: null,
|
||||||
@@ -641,6 +643,27 @@ function getChangeSetFromPlanJson(planJson: unknown): ChangeSet | null {
|
|||||||
return cs as ChangeSet;
|
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) {
|
async function findLatestChangeCarrierMessage(auth: AuthContext | null) {
|
||||||
const ctx = requireAuth(auth);
|
const ctx = requireAuth(auth);
|
||||||
const items = await prisma.chatMessage.findMany({
|
const items = await prisma.chatMessage.findMany({
|
||||||
@@ -755,8 +778,23 @@ async function sendPilotMessage(auth: AuthContext | null, textInput: string) {
|
|||||||
eventType: "assistant",
|
eventType: "assistant",
|
||||||
phase: "final",
|
phase: "final",
|
||||||
transient: false,
|
transient: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
changeSet,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -909,6 +947,7 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
id: ID!
|
id: ID!
|
||||||
role: String!
|
role: String!
|
||||||
text: String!
|
text: String!
|
||||||
|
messageKind: String
|
||||||
requestId: String
|
requestId: String
|
||||||
eventType: String
|
eventType: String
|
||||||
phase: String
|
phase: String
|
||||||
|
|||||||
Reference in New Issue
Block a user