feat(chat): add typed change-set summary message in timeline

This commit is contained in:
Ruslan Bakiev
2026-02-20 11:34:42 +07:00
parent 4219d03c56
commit e1f301c913
5 changed files with 149 additions and 4 deletions

View File

@@ -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,

View File

@@ -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<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) => {
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 });

View File

@@ -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