From fc8afabe26699c2b0c1f64286bf567dc2d7315b2 Mon Sep 17 00:00:00 2001 From: maksym Date: Wed, 15 Jul 2026 15:16:17 +0200 Subject: [PATCH 01/21] feat(chat): chat history index (Phase 1 API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `chat` slice: a Postgres INDEX over agents' S3 JSONL session transcripts, so chats are listable/filterable without fanning out to agent pods and remain available when a pod is down. Message content stays in S3 (source of truth); Postgres only indexes. - ChatSession + ChatFeedback models (FK cascade to Agent) + migration. - ChatSyncService reconciles the index from S3 (IFileGateway.list). Counts are monotonic lifetime totals — reconcile seeds/raises, never lowers (compaction shrinks the file; realtime will own the true total). - Endpoints (Owner/Admin): GET /chats, /chats/:id, /chats/:id/messages, POST /chats/sync. - Shared TranscriptReaderService (agent/file): surfaces compaction `summary` events as collapsed markers instead of a silent gap, and filters synthetic loop-control noise (continuation prompts + the duplicated pre-cutoff partial assistant chunk). The bridle transcript endpoint now delegates to it (byte-identical: user/assistant, no hygiene), removing ~90 lines of duplicated reader logic. - 11 unit specs (reader hygiene/summary/pagination, monotonic counts, reconcile); tsc clean. Co-Authored-By: Claude Opus 4.8 --- .../20260715131012_auto/migration.sql | 56 +++++ api/src/app.module.ts | 2 + api/src/slices/agent/agent/agent.prisma | 2 + api/src/slices/agent/file/domain/index.ts | 1 + .../domain/transcriptReader.service.spec.ts | 112 ++++++++++ .../file/domain/transcriptReader.service.ts | 194 ++++++++++++++++++ api/src/slices/agent/file/file.module.ts | 4 +- api/src/slices/bridle/bridle.controller.ts | 106 +++------- api/src/slices/chat/chat.controller.ts | 126 ++++++++++++ api/src/slices/chat/chat.module.ts | 22 ++ api/src/slices/chat/chat.prisma | 55 +++++ api/src/slices/chat/data/chat.gateway.spec.ts | 151 ++++++++++++++ api/src/slices/chat/data/chat.gateway.ts | 80 ++++++++ api/src/slices/chat/data/chat.mapper.ts | 65 ++++++ api/src/slices/chat/domain/chat.gateway.ts | 23 +++ api/src/slices/chat/domain/chat.types.ts | 74 +++++++ .../chat/domain/chatSync.service.spec.ts | 89 ++++++++ .../slices/chat/domain/chatSync.service.ts | 172 ++++++++++++++++ api/src/slices/chat/domain/index.ts | 3 + api/src/slices/chat/dtos/chatMessage.dto.ts | 51 +++++ api/src/slices/chat/dtos/chatSession.dto.ts | 50 +++++ api/src/slices/chat/dtos/filterChats.dto.ts | 49 +++++ api/src/slices/chat/dtos/index.ts | 4 + api/src/slices/chat/dtos/syncChats.dto.ts | 16 ++ 24 files changed, 1425 insertions(+), 82 deletions(-) create mode 100644 api/prisma/migrations/20260715131012_auto/migration.sql create mode 100644 api/src/slices/agent/file/domain/transcriptReader.service.spec.ts create mode 100644 api/src/slices/agent/file/domain/transcriptReader.service.ts create mode 100644 api/src/slices/chat/chat.controller.ts create mode 100644 api/src/slices/chat/chat.module.ts create mode 100644 api/src/slices/chat/chat.prisma create mode 100644 api/src/slices/chat/data/chat.gateway.spec.ts create mode 100644 api/src/slices/chat/data/chat.gateway.ts create mode 100644 api/src/slices/chat/data/chat.mapper.ts create mode 100644 api/src/slices/chat/domain/chat.gateway.ts create mode 100644 api/src/slices/chat/domain/chat.types.ts create mode 100644 api/src/slices/chat/domain/chatSync.service.spec.ts create mode 100644 api/src/slices/chat/domain/chatSync.service.ts create mode 100644 api/src/slices/chat/domain/index.ts create mode 100644 api/src/slices/chat/dtos/chatMessage.dto.ts create mode 100644 api/src/slices/chat/dtos/chatSession.dto.ts create mode 100644 api/src/slices/chat/dtos/filterChats.dto.ts create mode 100644 api/src/slices/chat/dtos/index.ts create mode 100644 api/src/slices/chat/dtos/syncChats.dto.ts diff --git a/api/prisma/migrations/20260715131012_auto/migration.sql b/api/prisma/migrations/20260715131012_auto/migration.sql new file mode 100644 index 0000000..49c9b3f --- /dev/null +++ b/api/prisma/migrations/20260715131012_auto/migration.sql @@ -0,0 +1,56 @@ +-- CreateTable +CREATE TABLE "ChatSession" ( + "id" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "externalUserId" TEXT NOT NULL, + "sessionKey" TEXT NOT NULL, + "title" TEXT, + "preview" TEXT, + "lastRole" TEXT, + "lastMessageAt" TIMESTAMP(3) NOT NULL, + "messageCount" INTEGER NOT NULL DEFAULT 0, + "userMessageCount" INTEGER NOT NULL DEFAULT 0, + "lastIndexedEventId" TEXT, + "lastIndexedSize" INTEGER NOT NULL DEFAULT 0, + "summary" TEXT, + "summaryAt" TIMESTAMP(3), + "insights" JSONB, + "archived" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ChatSession_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChatFeedback" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + "rating" INTEGER NOT NULL, + "comment" TEXT, + "source" TEXT NOT NULL, + "authorId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ChatFeedback_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ChatSession_agentId_lastMessageAt_idx" ON "ChatSession"("agentId", "lastMessageAt"); + +-- CreateIndex +CREATE INDEX "ChatSession_channel_lastMessageAt_idx" ON "ChatSession"("channel", "lastMessageAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ChatSession_agentId_sessionKey_key" ON "ChatSession"("agentId", "sessionKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "ChatFeedback_sessionId_messageId_authorId_key" ON "ChatFeedback"("sessionId", "messageId", "authorId"); + +-- AddForeignKey +ALTER TABLE "ChatSession" ADD CONSTRAINT "ChatSession_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatFeedback" ADD CONSTRAINT "ChatFeedback_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ChatSession"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/api/src/app.module.ts b/api/src/app.module.ts index e6461f2..9ddbecb 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -23,6 +23,7 @@ import { SettingModule } from './slices/setting/setting.module'; import { BridleModule } from './slices/bridle/bridle.module'; import { LlmModule } from './slices/llm/llm.module'; import { UsageModule } from './slices/usage/usage.module'; +import { ChatModule } from './slices/chat/chat.module'; import { KnowledgeModule } from './slices/reins/knowledge/knowledge.module'; import { SourceModule } from './slices/reins/source/source.module'; import { SkillModule } from './slices/skill/skill.module'; @@ -66,6 +67,7 @@ import { UserBrowserStateModule } from './slices/user/browserState/browserState. BridleModule, LlmModule, UsageModule, + ChatModule, KnowledgeModule, SourceModule, SkillModule, diff --git a/api/src/slices/agent/agent/agent.prisma b/api/src/slices/agent/agent/agent.prisma index deef2fd..4122693 100644 --- a/api/src/slices/agent/agent/agent.prisma +++ b/api/src/slices/agent/agent/agent.prisma @@ -3,6 +3,7 @@ import { Usage } from "../../usage/usage" import { LlmCredential } from "../../llm/llm" import { PaddockScenario } from "../../paddock/scenario/scenario" import { PaddockEvaluation } from "../../paddock/evaluation/evaluation" +import { ChatSession } from "../../chat/chat" model Agent { id String @id @default(uuid()) @@ -26,4 +27,5 @@ model Agent { usages Usage[] paddockScenarios PaddockScenario[] paddockEvaluations PaddockEvaluation[] + chatSessions ChatSession[] } diff --git a/api/src/slices/agent/file/domain/index.ts b/api/src/slices/agent/file/domain/index.ts index fea7328..61ab979 100644 --- a/api/src/slices/agent/file/domain/index.ts +++ b/api/src/slices/agent/file/domain/index.ts @@ -1,2 +1,3 @@ export * from './file.gateway'; export * from './file.types'; +export * from './transcriptReader.service'; diff --git a/api/src/slices/agent/file/domain/transcriptReader.service.spec.ts b/api/src/slices/agent/file/domain/transcriptReader.service.spec.ts new file mode 100644 index 0000000..c35e5fd --- /dev/null +++ b/api/src/slices/agent/file/domain/transcriptReader.service.spec.ts @@ -0,0 +1,112 @@ +import { TranscriptReaderService, TranscriptMessage } from './transcriptReader.service'; +import { IFileGateway } from './file.gateway'; +import { IFileChunk } from './file.types'; + +// Minimal IFileGateway stub: readRange returns the whole fixture in one chunk. +function fileStub(content: string): IFileGateway { + const size = Buffer.byteLength(content); + return { + readRange: async ( + _agentId: string, + path: string, + ): Promise => ({ + path, + content, + size, + totalSize: size, + offset: 0, + nextOffset: null, + hasMore: false, + updatedAt: new Date(0), + }), + } as unknown as IFileGateway; +} + +function jsonl(...events: unknown[]): string { + return events.map((e) => JSON.stringify(e)).join('\n') + '\n'; +} + +const reader = (content: string) => new TranscriptReaderService(fileStub(content)); +const roles = (m: TranscriptMessage[]) => m.map((x) => x.role); +const texts = (m: TranscriptMessage[]) => m.map((x) => x.text); + +describe('TranscriptReaderService', () => { + it('returns user+assistant by default, sorted by ts', async () => { + const content = jsonl( + { id: 'b', type: 'assistant', ts: 2, data: { text: 'hi there' } }, + { id: 'a', type: 'user', ts: 1, data: { text: 'hello' } }, + { id: 'c', type: 'tool_call', ts: 3, data: { name: 'exec' } }, + ); + const out = await reader(content).read('agent', 'p'); + expect(roles(out)).toEqual(['user', 'assistant']); // tool_call hidden by default + expect(texts(out)).toEqual(['hello', 'hi there']); + }); + + it('surfaces summary events when requested', async () => { + const content = jsonl( + { id: 's', type: 'summary', ts: 1, data: { text: '[ARCHIVED CONTEXT] gist [END]' } }, + { id: 'u', type: 'user', ts: 2, data: { text: 'and then?' } }, + ); + const withSummary = await reader(content).read('agent', 'p', { + types: ['user', 'assistant', 'summary'], + }); + expect(roles(withSummary)).toEqual(['summary', 'user']); + + const withoutSummary = await reader(content).read('agent', 'p'); + expect(roles(withoutSummary)).toEqual(['user']); // silent gap unless summary requested + }); + + it('drops the max_tokens duplicate: partial assistant + continuation prompt (legacy text)', async () => { + const content = jsonl( + { id: 'u', type: 'user', ts: 1, data: { text: 'write a poem' } }, + { id: 'p', type: 'assistant', ts: 2, data: { text: 'Roses are red,' } }, // partial + { id: 'c', type: 'user', ts: 3, data: { text: 'Your response was cut off. Continue…' } }, + { id: 'f', type: 'assistant', ts: 4, data: { text: 'Roses are red, violets are blue.' } }, + ); + const out = await reader(content).read('agent', 'p'); + expect(texts(out)).toEqual(['write a poem', 'Roses are red, violets are blue.']); + }); + + it('drops synthetic events tagged data.transient (new data)', async () => { + const content = jsonl( + { id: 'u', type: 'user', ts: 1, data: { text: 'q' } }, + { id: 'p', type: 'assistant', ts: 2, data: { text: 'chunk', transient: true } }, + { id: 'c', type: 'user', ts: 3, data: { text: 'continue', transient: true } }, + { id: 'f', type: 'assistant', ts: 4, data: { text: 'full answer' } }, + ); + const out = await reader(content).read('agent', 'p'); + expect(texts(out)).toEqual(['q', 'full answer']); + }); + + it('filterTransient=false preserves raw order byte-for-byte (bridle widget parity)', async () => { + const content = jsonl( + { id: 'u', type: 'user', ts: 1, data: { text: 'q' } }, + { id: 'p', type: 'assistant', ts: 2, data: { text: 'part' } }, + { id: 'c', type: 'user', ts: 3, data: { text: 'Your response was cut off. Continue…' } }, + { id: 'f', type: 'assistant', ts: 4, data: { text: 'part full' } }, + ); + const out = await reader(content).read('agent', 'p', { filterTransient: false }); + expect(out).toHaveLength(4); // nothing filtered + }); + + it('paginates tail-first via page()', () => { + const msgs: TranscriptMessage[] = Array.from({ length: 5 }, (_, i) => ({ + id: `m${i}`, + role: 'user', + text: `#${i}`, + ts: i, + })); + const latest = TranscriptReaderService.page(msgs, undefined, 2); + expect(texts(latest.messages)).toEqual(['#3', '#4']); + expect(latest.hasMore).toBe(true); + + const older = TranscriptReaderService.page(msgs, latest.nextCursor!, 2); + expect(texts(older.messages)).toEqual(['#1', '#2']); + expect(older.hasMore).toBe(true); + + const oldest = TranscriptReaderService.page(msgs, older.nextCursor!, 2); + expect(texts(oldest.messages)).toEqual(['#0']); + expect(oldest.hasMore).toBe(false); + expect(oldest.nextCursor).toBeNull(); + }); +}); diff --git a/api/src/slices/agent/file/domain/transcriptReader.service.ts b/api/src/slices/agent/file/domain/transcriptReader.service.ts new file mode 100644 index 0000000..bd2d420 --- /dev/null +++ b/api/src/slices/agent/file/domain/transcriptReader.service.ts @@ -0,0 +1,194 @@ +import { Injectable } from '@nestjs/common'; +import { IFileGateway } from './file.gateway'; + +/** One replayable line of a chat transcript. `role` is the source Event type. */ +export interface TranscriptMessage { + id: string; + role: 'user' | 'assistant' | 'summary' | 'tool_call' | 'tool_result' | 'system'; + text: string; + ts: number; +} + +export interface ReadTranscriptOptions { + /** Event types to surface. Default: `['user', 'assistant']`. */ + types?: TranscriptMessage['role'][]; + /** + * Drop synthetic loop-control events (continuation prompts + partial + * assistant chunks) so the transcript reads cleanly and has no duplicates. + * Default: true. Pass false to preserve raw file order byte-for-byte + * (the bridle widget replay does this). + */ + filterTransient?: boolean; +} + +// Streamed in 512 KB blocks; hard-capped so a pathological session file can't +// blow the heap — anything bigger should be exported via the Files ZIP. +const BLOCK_BYTES = 512 * 1024; +const MAX_TRANSCRIPT_BYTES = 20 * 1024 * 1024; + +// Prefix shared by both continuation prompts injected by the runtime loop +// (CONTINUATION_PROMPT and buildAnchoredContinuationPrompt) — the reliable +// legacy signal for data written before the `data.transient` marker existed. +const CONTINUATION_PREFIX = 'Your response was cut off'; + +interface RawEvent { + id?: string; + type?: string; + ts?: number; + data?: { text?: string; transient?: boolean; name?: string; params?: unknown; result?: unknown }; +} + +/** + * Reads an agent's append-only JSONL session transcript from S3 (via + * IFileGateway) and turns it into replayable messages. Shared by the bridle + * widget replay and the admin chat-history view. + * + * Two behaviors layered on top of the raw read (both opt-in via options): + * - hygiene: filter runtime loop-control noise (fix 8) — continuation prompts + * and the pre-cutoff partial assistant chunk that is re-emitted in full. + * - `summary` events are surfaced as a collapsed marker (compaction folds old + * turns into a summary; hiding it would leave a silent gap in the history). + */ +@Injectable() +export class TranscriptReaderService { + constructor(private readonly files: IFileGateway) {} + + /** Full transcript, oldest-first. Callers paginate with {@link page}. */ + async read( + agentId: string, + path: string, + opts: ReadTranscriptOptions = {}, + ): Promise { + const types = new Set(opts.types ?? ['user', 'assistant']); + const filterTransient = opts.filterTransient ?? true; + + const events = this.parse(await this.readRaw(agentId, path)); + const transient = filterTransient + ? this.markTransient(events) + : new Set(); + + const messages: TranscriptMessage[] = []; + events.forEach((evt, i) => { + if (transient.has(i)) return; + if (!evt.type || !evt.id || typeof evt.ts !== 'number') return; + if (!types.has(evt.type)) return; + const text = this.render(evt); + if (text === null) return; + messages.push({ id: evt.id, role: evt.type as TranscriptMessage['role'], text, ts: evt.ts }); + }); + messages.sort((a, b) => a.ts - b.ts); + return messages; + } + + /** + * Tail-first cursor pagination over an already-read message list. Cursor is + * the index into the sorted-ascending list where the next (older) page ENDS, + * exclusive; omit it for the latest page. Mirrors the bridle transcript API. + */ + static page( + all: TranscriptMessage[], + cursor: string | undefined, + limit: number, + ): { messages: TranscriptMessage[]; nextCursor: string | null; hasMore: boolean } { + const end = TranscriptReaderService.parseCursor(cursor, all.length); + const start = Math.max(0, end - limit); + const hasMore = start > 0; + return { + messages: all.slice(start, end), + nextCursor: hasMore ? String(start) : null, + hasMore, + }; + } + + private static parseCursor(cursor: string | undefined, total: number): number { + if (!cursor) return total; + const parsed = parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) return total; + return Math.min(parsed, total); + } + + private async readRaw(agentId: string, path: string): Promise { + let content = ''; + let offset = 0; + for (;;) { + const chunk = await this.files.readRange(agentId, path, offset, BLOCK_BYTES); + content += chunk.content; + if (content.length > MAX_TRANSCRIPT_BYTES) { + throw new Error( + `Transcript exceeds ${MAX_TRANSCRIPT_BYTES} bytes — export via Files instead`, + ); + } + if (!chunk.hasMore || chunk.nextOffset === null) break; + offset = chunk.nextOffset; + } + return content; + } + + private parse(content: string): RawEvent[] { + const events: RawEvent[] = []; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + events.push(JSON.parse(trimmed) as RawEvent); + } catch { + // JSONL writers occasionally truncate the tail mid-flush; one bad + // line shouldn't kill the whole replay. + } + } + return events; + } + + // Indices (in file/append order) of runtime loop-control noise to drop: + // - any event explicitly tagged `data.transient` (new data); + // - a `user` continuation prompt (legacy: text starts with the known prefix); + // - an `assistant` immediately followed by such a continuation `user` — that + // is the pre-cutoff partial chunk; the final full assistant is re-emitted + // later, so keeping the partial would duplicate content. + private markTransient(events: RawEvent[]): Set { + const isContinuationUser = (e?: RawEvent): boolean => + !!e && + e.type === 'user' && + (e.data?.transient === true || + (typeof e.data?.text === 'string' && e.data.text.startsWith(CONTINUATION_PREFIX))); + + const drop = new Set(); + events.forEach((e, i) => { + if (e.data?.transient === true) drop.add(i); + if (isContinuationUser(e)) drop.add(i); + if (e.type === 'assistant' && isContinuationUser(events[i + 1])) drop.add(i); + }); + return drop; + } + + // Renders an event to display text. Returns null to skip (e.g. empty body). + private render(evt: RawEvent): string | null { + if (evt.type === 'user' || evt.type === 'assistant') { + return evt.data?.text ? evt.data.text : null; + } + if (evt.type === 'summary') { + // Compaction stores the archive under data.text already wrapped in + // [ARCHIVED CONTEXT …] markers; surface it as-is so the UI can collapse it. + return evt.data?.text ? evt.data.text : null; + } + if (evt.type === 'tool_call') { + const name = evt.data?.name ?? 'tool'; + return `${name}(${this.truncate(JSON.stringify(evt.data?.params ?? {}))})`; + } + if (evt.type === 'tool_result') { + return this.truncate( + typeof evt.data?.result === 'string' + ? evt.data.result + : JSON.stringify(evt.data?.result ?? ''), + ); + } + if (evt.type === 'system') { + return evt.data?.text ? evt.data.text : null; + } + return null; + } + + private truncate(s: string, max = 2000): string { + return s.length > max ? `${s.slice(0, max)}…[+${s.length - max} chars]` : s; + } +} diff --git a/api/src/slices/agent/file/file.module.ts b/api/src/slices/agent/file/file.module.ts index 59d3859..a124c4e 100644 --- a/api/src/slices/agent/file/file.module.ts +++ b/api/src/slices/agent/file/file.module.ts @@ -5,6 +5,7 @@ import { BridleModule } from '#/bridle/bridle.module'; import { FileController } from './file.controller'; import { IFileGateway } from './domain/file.gateway'; import { S3FileGateway } from './data/file.gateway'; +import { TranscriptReaderService } from './domain/transcriptReader.service'; // AgentModule must be a forwardRef here because BridleModule (which imports // FileModule) is now also imported by AgentModule, creating @@ -21,7 +22,8 @@ import { S3FileGateway } from './data/file.gateway'; provide: IFileGateway, useClass: S3FileGateway, }, + TranscriptReaderService, ], - exports: [IFileGateway], + exports: [IFileGateway, TranscriptReaderService], }) export class FileModule {} diff --git a/api/src/slices/bridle/bridle.controller.ts b/api/src/slices/bridle/bridle.controller.ts index 346bfd6..00a9525 100644 --- a/api/src/slices/bridle/bridle.controller.ts +++ b/api/src/slices/bridle/bridle.controller.ts @@ -29,7 +29,11 @@ import { TranscriptMessageDto, } from './dtos'; import { FlatResponse } from './core'; -import { IFileGateway } from '#/agent/file/domain'; +import { + IFileGateway, + TranscriptReaderService, + TranscriptMessage, +} from '#/agent/file/domain'; @ApiTags('bridle') @Controller('api/agent') @@ -40,6 +44,8 @@ export class BridleController { private readonly hub: IBridleGateway, @Inject(forwardRef(() => IFileGateway)) private readonly fileGateway: IFileGateway, + @Inject(forwardRef(() => TranscriptReaderService)) + private readonly transcriptReader: TranscriptReaderService, ) {} @ApiOperation({ @@ -161,9 +167,15 @@ export class BridleController { const limit = query.limit ?? 50; const path = `data/sessions/bridle:${channel}.jsonl`; - let allMessages: TranscriptMessageDto[]; + let all: TranscriptMessage[]; try { - allMessages = await this.readAllTranscriptMessages(agentId, path); + // Byte-identical to the previous inline reader: user/assistant only, raw + // order (no transient filtering). The admin chat-history view uses the + // same service but opts into summaries + hygiene. + all = await this.transcriptReader.read(agentId, path, { + types: ['user', 'assistant'], + filterTransient: false, + }); } catch (err) { const getStatus = (err as { getStatus?: () => number }).getStatus; const status = @@ -171,88 +183,20 @@ export class BridleController { ? getStatus.call(err) : ((err as { status?: number; statusCode?: number }).status ?? (err as { statusCode?: number }).statusCode); - if (status === 404) { - return { messages: [], channel, nextCursor: null, hasMore: false }; - } - this.logger.warn( - `Transcript read failed for ${agentId}/${channel}: ${(err as Error).message}`, - ); - return { messages: [], channel, nextCursor: null, hasMore: false }; - } - - const endIdx = this.parseCursor(query.cursor, allMessages.length); - const startIdx = Math.max(0, endIdx - limit); - const page = allMessages.slice(startIdx, endIdx); - const hasMore = startIdx > 0; - - return { - messages: page, - channel, - nextCursor: hasMore ? String(startIdx) : null, - hasMore, - }; - } - - // Read the full JSONL transcript via range reads, parse all visible - // user/assistant lines, return sorted ascending by ts. Large files are - // streamed in 512 KB blocks so we never hit the editor's MAX_BYTES cap. - // Hard ceiling: 20 MB to avoid pathological reads — anything bigger - // should be exported via the Files ZIP download. - private async readAllTranscriptMessages( - agentId: string, - path: string, - ): Promise { - const MAX_TRANSCRIPT_BYTES = 20 * 1024 * 1024; - let content = ''; - let offset = 0; - while (true) { - const chunk = await this.fileGateway.readRange( - agentId, - path, - offset, - 512 * 1024, - ); - content += chunk.content; - if (content.length > MAX_TRANSCRIPT_BYTES) { - throw new Error( - `Transcript exceeds ${MAX_TRANSCRIPT_BYTES} bytes — export via Files instead`, + if (status !== 404) { + this.logger.warn( + `Transcript read failed for ${agentId}/${channel}: ${(err as Error).message}`, ); } - if (!chunk.hasMore || chunk.nextOffset === null) break; - offset = chunk.nextOffset; - } - - const messages: TranscriptMessageDto[] = []; - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const evt = JSON.parse(trimmed) as { - id?: string; - type?: string; - ts?: number; - data?: { text?: string }; - }; - if (evt.type !== 'user' && evt.type !== 'assistant') continue; - const text = evt.data?.text; - if (!text || !evt.id || typeof evt.ts !== 'number') continue; - messages.push({ id: evt.id, role: evt.type, text, ts: evt.ts }); - } catch { - // Skip malformed lines — JSONL writers occasionally truncate the - // tail mid-flush; one bad line shouldn't kill the whole replay. - } + return { messages: [], channel, nextCursor: null, hasMore: false }; } - messages.sort((a, b) => a.ts - b.ts); - return messages; - } - // Cursor is the index *into the sorted-ascending message list* where the - // next page ENDS (exclusive). Default: total length (= latest page). - private parseCursor(cursor: string | undefined, total: number): number { - if (!cursor) return total; - const parsed = parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) return total; - return Math.min(parsed, total); + const { messages, nextCursor, hasMore } = TranscriptReaderService.page( + all, + query.cursor, + limit, + ); + return { messages: messages as TranscriptMessageDto[], channel, nextCursor, hasMore }; } @ApiOperation({ diff --git a/api/src/slices/chat/chat.controller.ts b/api/src/slices/chat/chat.controller.ts new file mode 100644 index 0000000..f5e6f3b --- /dev/null +++ b/api/src/slices/chat/chat.controller.ts @@ -0,0 +1,126 @@ +import { + Controller, + Get, + Post, + Param, + Query, + Body, + NotFoundException, + UseGuards, + Logger, +} from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiOkResponse, +} from '@nestjs/swagger'; +import { JwtAuthGuard, Roles, RolesGuard } from '#/user/auth/guards'; +import { UserRoleTypes } from '#/user/user/domain'; +import { TranscriptReaderService, TranscriptMessage } from '#/agent/file/domain'; +import { IChatGateway, ChatSyncService } from './domain'; +import { + FilterChatsDto, + ChatListResponseDto, + ChatSessionDto, + ChatMessagesQueryDto, + ChatMessagesResponseDto, + SyncChatsDto, + SyncChatsResponseDto, +} from './dtos'; + +const ALLOWED_TYPES: TranscriptMessage['role'][] = [ + 'user', + 'assistant', + 'summary', + 'tool_call', + 'tool_result', + 'system', +]; +const DEFAULT_TYPES: TranscriptMessage['role'][] = ['user', 'assistant', 'summary']; + +@ApiTags('chats') +@ApiBearerAuth() +@Controller('chats') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRoleTypes.Owner, UserRoleTypes.Admin) +export class ChatController { + private readonly logger = new Logger(ChatController.name); + + constructor( + private readonly chats: IChatGateway, + private readonly reader: TranscriptReaderService, + private readonly sync: ChatSyncService, + ) {} + + @ApiOperation({ + description: 'List chat sessions (index). Filter by agent, channel, search; paginated.', + operationId: 'getChats', + }) + @ApiOkResponse({ type: ChatListResponseDto }) + @Get() + async list(@Query() filter: FilterChatsDto): Promise { + const page = filter.page ?? 1; + const perPage = filter.perPage ?? 50; + const { items, total } = await this.chats.list({ ...filter, page, perPage }); + return { items, total, page, perPage }; + } + + @ApiOperation({ description: 'Get one chat session (index metadata).', operationId: 'getChat' }) + @ApiOkResponse({ type: ChatSessionDto }) + @Get(':id') + async detail(@Param('id') id: string): Promise { + const session = await this.chats.findById(id); + if (!session) throw new NotFoundException(`Chat ${id} not found`); + return session; + } + + @ApiOperation({ + description: + "Replay a chat session's transcript from S3, tail-first. `summary` markers are " + + 'shown inline (compaction folds old turns into them); synthetic loop-control ' + + 'events are filtered. Admins may add tool_call,tool_result via `types`.', + operationId: 'getChatMessages', + }) + @ApiOkResponse({ type: ChatMessagesResponseDto }) + @Get(':id/messages') + async messages( + @Param('id') id: string, + @Query() q: ChatMessagesQueryDto, + ): Promise { + const session = await this.chats.findById(id); + if (!session) throw new NotFoundException(`Chat ${id} not found`); + + const types = q.types + ? (q.types + .split(',') + .map((t) => t.trim()) + .filter((t) => (ALLOWED_TYPES as string[]).includes(t)) as TranscriptMessage['role'][]) + : DEFAULT_TYPES; + + const path = `data/sessions/${session.sessionKey}.jsonl`; + let all: TranscriptMessage[]; + try { + all = await this.reader.read(session.agentId, path, { types, filterTransient: true }); + } catch (err) { + // Missing/unreadable file → empty transcript (row may predate the file, or + // it was archived/reset). Don't 500 the whole page. + this.logger.warn( + `transcript read failed for ${session.agentId}/${session.sessionKey}: ${(err as Error).message}`, + ); + return { messages: [], nextCursor: null, hasMore: false }; + } + + return TranscriptReaderService.page(all, q.cursor, q.limit ?? 50); + } + + @ApiOperation({ + description: 'Reconcile the chat index against S3 session files (all agents, or one).', + operationId: 'syncChats', + }) + @ApiOkResponse({ type: SyncChatsResponseDto }) + @Post('sync') + async syncChats(@Body() dto: SyncChatsDto): Promise { + return this.sync.syncAll(dto.agentId); + } +} diff --git a/api/src/slices/chat/chat.module.ts b/api/src/slices/chat/chat.module.ts new file mode 100644 index 0000000..325226e --- /dev/null +++ b/api/src/slices/chat/chat.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { FileModule } from '#/agent/file/file.module'; +import { AgentModule } from '#/agent/agent/agent.module'; +import { ChatController } from './chat.controller'; +import { IChatGateway, ChatSyncService } from './domain'; +import { ChatGateway } from './data/chat.gateway'; +import { ChatMapper } from './data/chat.mapper'; + +@Module({ + imports: [FileModule, AgentModule], + controllers: [ChatController], + providers: [ + ChatMapper, + ChatSyncService, + { + provide: IChatGateway, + useClass: ChatGateway, + }, + ], + exports: [IChatGateway], +}) +export class ChatModule {} diff --git a/api/src/slices/chat/chat.prisma b/api/src/slices/chat/chat.prisma new file mode 100644 index 0000000..4b1a6bb --- /dev/null +++ b/api/src/slices/chat/chat.prisma @@ -0,0 +1,55 @@ +import { Agent } from "../agent/agent/agent" + +// Chat history index. The message CONTENT stays in the agent runtime's +// append-only JSONL (S3, `agents/{agentId}/data/sessions/{channel}:{userId}.jsonl`); +// this table is only a queryable INDEX — one row per session — so the admin can +// list/filter chats without fanning out to agent pods, and history survives a +// pod being down. Populated by S3 reconciliation (ChatSyncService) and, later, +// realtime `session_activity` events over the agent↔hub socket. +model ChatSession { + id String @id @default(uuid()) + agentId String + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + channel String // bridle | telegram | slack | internal + externalUserId String // bridle clientId | tg chat_id | slack user id + sessionKey String // "{channel}:{externalUserId}" = JSONL file basename + title String? // tg chat title / slack username / null + preview String? // last message text, truncated ~200 chars + lastRole String? // user | assistant + lastMessageAt DateTime + + // Counts are MONOTONIC lifetime totals owned by the realtime path — compaction + // shrinks the JSONL, so re-counting the file would regress them. Reconcile only + // seeds/raises them (floor), never lowers. See lastIndexedEventId watermark. + messageCount Int @default(0) + userMessageCount Int @default(0) + lastIndexedEventId String? // last Event.id folded into the counts (dedup) + lastIndexedSize Int @default(0) // S3 object byte size at last reconcile (change detect) + + summary String? @db.Text // LLM gist (Phase 4) + summaryAt DateTime? + insights Json? // { topics[], sentiment, resolved, language } + archived Boolean @default(false) // *.archived.jsonl sessions get their own row + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + feedback ChatFeedback[] + + @@unique([agentId, sessionKey]) + @@index([agentId, lastMessageAt]) + @@index([channel, lastMessageAt]) +} + +model ChatFeedback { + id String @id @default(uuid()) + sessionId String + session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + messageId String // Event.id of the rated assistant message + rating Int // 1 | -1 + comment String? + source String // admin | app | telegram + authorId String? // clientId / tg user / admin user id + createdAt DateTime @default(now()) + + @@unique([sessionId, messageId, authorId]) +} diff --git a/api/src/slices/chat/data/chat.gateway.spec.ts b/api/src/slices/chat/data/chat.gateway.spec.ts new file mode 100644 index 0000000..ec4d34b --- /dev/null +++ b/api/src/slices/chat/data/chat.gateway.spec.ts @@ -0,0 +1,151 @@ +import { ChatGateway } from './chat.gateway'; +import { ChatMapper } from './chat.mapper'; +import { IChatReconcileInput } from '../domain'; + +// In-memory Prisma stub — mirrors ranch's browser.gateway.spec pattern. +function makePrismaStub() { + const rows: Record> = {}; + + const matches = (r: Record, where: Record = {}) => { + if (where.agentId && r.agentId !== where.agentId) return false; + if (where.channel !== undefined) { + const c = where.channel as string | { not?: string }; + if (typeof c === 'string' && r.channel !== c) return false; + if (typeof c === 'object' && c.not !== undefined && r.channel === c.not) return false; + } + if (where.archived !== undefined && r.archived !== where.archived) return false; + return true; + }; + + const chatSession = { + findUnique: jest.fn(async ({ where }: { where: Record }) => { + if (where.id) return rows[where.id as string] ?? null; + if (where.agentId_sessionKey) { + const { agentId, sessionKey } = where.agentId_sessionKey; + return ( + Object.values(rows).find( + (r) => r.agentId === agentId && r.sessionKey === sessionKey, + ) ?? null + ); + } + return null; + }), + create: jest.fn(async ({ data }: { data: Record }) => { + rows[data.id as string] = { + summary: null, + summaryAt: null, + insights: null, + lastIndexedEventId: null, + title: null, + createdAt: new Date(0), + updatedAt: new Date(0), + ...data, + }; + return rows[data.id as string]; + }), + update: jest.fn(async ({ where, data }: { where: { id: string }; data: Record }) => { + rows[where.id] = { ...rows[where.id], ...data }; + return rows[where.id]; + }), + findMany: jest.fn( + async ({ where, skip = 0, take = 1000 }: { where?: Record; skip?: number; take?: number }) => { + const list = Object.values(rows) + .filter((r) => matches(r, where)) + .sort((a, b) => (b.lastMessageAt as Date).getTime() - (a.lastMessageAt as Date).getTime()); + return list.slice(skip, skip + take); + }, + ), + count: jest.fn(async ({ where }: { where?: Record }) => + Object.values(rows).filter((r) => matches(r, where)).length, + ), + }; + + const prisma = { + chatSession, + $transaction: jest.fn(async (ops: Promise[]) => Promise.all(ops)), + }; + return { prisma, rows }; +} + +function input(over: Partial = {}): IChatReconcileInput { + return { + agentId: 'agent-1', + sessionKey: 'bridle:admin', + channel: 'bridle', + externalUserId: 'admin', + title: null, + archived: false, + size: 100, + lastMessageAt: new Date(1000), + preview: 'hi', + lastRole: 'user', + messageCount: 4, + userMessageCount: 2, + ...over, + }; +} + +function newGateway() { + const { prisma, rows } = makePrismaStub(); + const gw = new ChatGateway( + prisma as unknown as ConstructorParameters[0], + new ChatMapper(), + ); + return { gw, rows }; +} + +describe('ChatGateway.reconcileUpsert', () => { + it('creates a row on first index with the file counts as the floor', async () => { + const { gw } = newGateway(); + const s = await gw.reconcileUpsert(input({ messageCount: 4, userMessageCount: 2 })); + expect(s.messageCount).toBe(4); + expect(s.userMessageCount).toBe(2); + expect(s.lastIndexedSize).toBe(100); + }); + + it('never lowers counts after compaction shrinks the file, but refreshes preview/lastMessageAt', async () => { + const { gw } = newGateway(); + await gw.reconcileUpsert(input({ messageCount: 40, userMessageCount: 20, size: 5000 })); + + // Compaction shrank the file → fewer viewable messages, newer last message. + const after = await gw.reconcileUpsert( + input({ + messageCount: 12, + userMessageCount: 6, + size: 900, + preview: 'latest', + lastMessageAt: new Date(2000), + }), + ); + + expect(after.messageCount).toBe(40); // monotonic — not lowered + expect(after.userMessageCount).toBe(20); + expect(after.preview).toBe('latest'); // freshness refreshed + expect(after.lastMessageAt).toEqual(new Date(2000)); + expect(after.lastIndexedSize).toBe(900); + }); + + it('raises counts when the file grew', async () => { + const { gw } = newGateway(); + await gw.reconcileUpsert(input({ messageCount: 4, userMessageCount: 2 })); + const after = await gw.reconcileUpsert(input({ messageCount: 9, userMessageCount: 5 })); + expect(after.messageCount).toBe(9); + expect(after.userMessageCount).toBe(5); + }); +}); + +describe('ChatGateway.list', () => { + it('hides internal channel unless includeInternal, and returns total', async () => { + const { gw } = newGateway(); + await gw.reconcileUpsert(input({ sessionKey: 'bridle:a', channel: 'bridle', lastMessageAt: new Date(3000) })); + await gw.reconcileUpsert(input({ sessionKey: 'telegram:b', channel: 'telegram', lastMessageAt: new Date(2000) })); + await gw.reconcileUpsert(input({ sessionKey: 'internal:heartbeat', channel: 'internal', lastMessageAt: new Date(1000) })); + + const visible = await gw.list({ agentId: 'agent-1' }); + expect(visible.total).toBe(2); + expect(visible.items.map((i) => i.channel)).toEqual(['bridle', 'telegram']); // desc by lastMessageAt + + const all = await gw.list({ agentId: 'agent-1', includeInternal: true }); + expect(all.total).toBe(3); + }); +}); diff --git a/api/src/slices/chat/data/chat.gateway.ts b/api/src/slices/chat/data/chat.gateway.ts new file mode 100644 index 0000000..889db5d --- /dev/null +++ b/api/src/slices/chat/data/chat.gateway.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '#/setup/prisma/prisma.service'; +import { + IChatGateway, + IChatFilter, + IChatListResult, + IChatReconcileInput, + IChatSessionData, +} from '../domain'; +import { ChatMapper } from './chat.mapper'; + +const DEFAULT_PER_PAGE = 50; + +@Injectable() +export class ChatGateway extends IChatGateway { + constructor( + private prisma: PrismaService, + private mapper: ChatMapper, + ) { + super(); + } + + async list(filter: IChatFilter): Promise { + const where = this.buildWhere(filter); + const perPage = filter.perPage ?? DEFAULT_PER_PAGE; + const page = filter.page ?? 1; + + const [records, total] = await this.prisma.$transaction([ + this.prisma.chatSession.findMany({ + where, + orderBy: { lastMessageAt: 'desc' }, + skip: (page - 1) * perPage, + take: perPage, + }), + this.prisma.chatSession.count({ where }), + ]); + + return { items: records.map((r) => this.mapper.toEntity(r)), total }; + } + + async findById(id: string): Promise { + const record = await this.prisma.chatSession.findUnique({ where: { id } }); + return record ? this.mapper.toEntity(record) : null; + } + + async reconcileUpsert(input: IChatReconcileInput): Promise { + const existing = await this.prisma.chatSession.findUnique({ + where: { agentId_sessionKey: { agentId: input.agentId, sessionKey: input.sessionKey } }, + }); + + const record = existing + ? await this.prisma.chatSession.update({ + where: { id: existing.id }, + data: this.mapper.toReconcileUpdate(input, existing), + }) + : await this.prisma.chatSession.create({ data: this.mapper.toCreate(input) }); + + return this.mapper.toEntity(record); + } + + private buildWhere(filter: IChatFilter): Prisma.ChatSessionWhereInput { + const where: Prisma.ChatSessionWhereInput = {}; + if (filter.agentId) where.agentId = filter.agentId; + + if (filter.channel) where.channel = filter.channel; + else if (!filter.includeInternal) where.channel = { not: 'internal' }; + + if (filter.archived !== undefined) where.archived = filter.archived; + + if (filter.search) { + where.OR = [ + { title: { contains: filter.search, mode: 'insensitive' } }, + { preview: { contains: filter.search, mode: 'insensitive' } }, + { externalUserId: { contains: filter.search, mode: 'insensitive' } }, + ]; + } + return where; + } +} diff --git a/api/src/slices/chat/data/chat.mapper.ts b/api/src/slices/chat/data/chat.mapper.ts new file mode 100644 index 0000000..181be0c --- /dev/null +++ b/api/src/slices/chat/data/chat.mapper.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { ChatSession } from '@prisma/client'; +import { IChatReconcileInput, IChatSessionData } from '../domain'; + +@Injectable() +export class ChatMapper { + toEntity(record: ChatSession): IChatSessionData { + return { + id: record.id, + agentId: record.agentId, + channel: record.channel, + externalUserId: record.externalUserId, + sessionKey: record.sessionKey, + title: record.title, + preview: record.preview, + lastRole: record.lastRole, + lastMessageAt: record.lastMessageAt, + messageCount: record.messageCount, + userMessageCount: record.userMessageCount, + lastIndexedEventId: record.lastIndexedEventId, + lastIndexedSize: record.lastIndexedSize, + summary: record.summary, + summaryAt: record.summaryAt, + insights: record.insights, + archived: record.archived, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; + } + + toCreate(input: IChatReconcileInput) { + return { + id: `chat-${crypto.randomUUID()}`, + agentId: input.agentId, + channel: input.channel, + externalUserId: input.externalUserId, + sessionKey: input.sessionKey, + title: input.title, + preview: input.preview, + lastRole: input.lastRole, + lastMessageAt: input.lastMessageAt, + messageCount: input.messageCount, + userMessageCount: input.userMessageCount, + lastIndexedSize: input.size, + archived: input.archived, + }; + } + + // Reconcile update: freshness always overwrites; counts are monotonic + // (never lowered — the existing row's value is the floor). + toReconcileUpdate(input: IChatReconcileInput, existing: ChatSession) { + return { + channel: input.channel, + externalUserId: input.externalUserId, + title: input.title ?? existing.title, + preview: input.preview, + lastRole: input.lastRole, + lastMessageAt: input.lastMessageAt, + archived: input.archived, + lastIndexedSize: input.size, + messageCount: Math.max(existing.messageCount, input.messageCount), + userMessageCount: Math.max(existing.userMessageCount, input.userMessageCount), + }; + } +} diff --git a/api/src/slices/chat/domain/chat.gateway.ts b/api/src/slices/chat/domain/chat.gateway.ts new file mode 100644 index 0000000..c836b95 --- /dev/null +++ b/api/src/slices/chat/domain/chat.gateway.ts @@ -0,0 +1,23 @@ +import { + IChatFilter, + IChatListResult, + IChatReconcileInput, + IChatSessionData, +} from './chat.types'; + +/** + * Persistence port for the chat-history index. Abstract class (not interface) + * so it can double as a Nest DI token — see ranch's IUsageGateway/ILlmGateway. + */ +export abstract class IChatGateway { + abstract list(filter: IChatFilter): Promise; + abstract findById(id: string): Promise; + + /** + * Upsert a session row from reconciled file metadata. Freshness fields + * (lastMessageAt/preview/lastRole/title/archived/size) are always written; + * counts are monotonic — seeded on first index, only ever raised, never + * lowered (compaction shrinks the file, realtime owns the true total). + */ + abstract reconcileUpsert(input: IChatReconcileInput): Promise; +} diff --git a/api/src/slices/chat/domain/chat.types.ts b/api/src/slices/chat/domain/chat.types.ts new file mode 100644 index 0000000..cb1a567 --- /dev/null +++ b/api/src/slices/chat/domain/chat.types.ts @@ -0,0 +1,74 @@ +// Channels a chat session can originate from. `internal` = cron/heartbeat +// sessions — indexed but hidden from the default list. +export const ChatChannelTypes = { + Bridle: 'bridle', + Telegram: 'telegram', + Slack: 'slack', + Internal: 'internal', +} as const; +export type ChatChannel = (typeof ChatChannelTypes)[keyof typeof ChatChannelTypes]; + +/** A ChatSession index row (domain view of the Prisma model). */ +export interface IChatSessionData { + id: string; + agentId: string; + channel: string; + externalUserId: string; + sessionKey: string; + title: string | null; + preview: string | null; + lastRole: string | null; + lastMessageAt: Date; + messageCount: number; + userMessageCount: number; + lastIndexedEventId: string | null; + lastIndexedSize: number; + summary: string | null; + summaryAt: Date | null; + insights: unknown | null; + archived: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface IChatFilter { + agentId?: string; + channel?: string; + search?: string; // matches title / preview / externalUserId + archived?: boolean; // default false (hide archived) + includeInternal?: boolean; // default false (hide internal channel) + page?: number; + perPage?: number; +} + +export interface IChatListResult { + items: IChatSessionData[]; + total: number; +} + +/** + * Metadata derived from a session's S3 JSONL file during reconciliation. + * Counts are the current *viewable* (post-hygiene) user/assistant totals — the + * gateway seeds/raises with them but never lowers an existing count. + */ +export interface IChatReconcileInput { + agentId: string; + sessionKey: string; + channel: string; + externalUserId: string; + title: string | null; + archived: boolean; + size: number; // S3 object byte size (change detection) + lastMessageAt: Date; + preview: string | null; + lastRole: string | null; + messageCount: number; + userMessageCount: number; +} + +export interface IChatSyncResult { + scannedAgents: number; + scannedFiles: number; + upserted: number; + skipped: number; // unchanged since last reconcile +} diff --git a/api/src/slices/chat/domain/chatSync.service.spec.ts b/api/src/slices/chat/domain/chatSync.service.spec.ts new file mode 100644 index 0000000..71ef6aa --- /dev/null +++ b/api/src/slices/chat/domain/chatSync.service.spec.ts @@ -0,0 +1,89 @@ +import { ChatSyncService } from './chatSync.service'; +import { IChatReconcileInput, IChatSessionData } from './chat.types'; +import { IChatGateway } from './chat.gateway'; +import { IAgentGateway } from '#/agent/agent/domain/agent.gateway'; +import { IFileGateway, IFileChunk, TranscriptReaderService } from '#/agent/file/domain'; + +function jsonl(...events: unknown[]): string { + return events.map((e) => JSON.stringify(e)).join('\n') + '\n'; +} + +function fileStub( + files: { path: string; size: number }[], + contents: Record, +): IFileGateway { + return { + list: async () => files.map((f) => ({ path: f.path, size: f.size, updatedAt: new Date(0) })), + readRange: async (_a: string, path: string): Promise => { + const content = contents[path] ?? ''; + const size = Buffer.byteLength(content); + return { path, content, size, totalSize: size, offset: 0, nextOffset: null, hasMore: false, updatedAt: new Date(0) }; + }, + } as unknown as IFileGateway; +} + +function chatsStub(existing: Partial[]) { + const upserts: IChatReconcileInput[] = []; + const chats = { + list: async () => ({ items: existing as IChatSessionData[], total: existing.length }), + reconcileUpsert: async (i: IChatReconcileInput) => { + upserts.push(i); + return i as unknown as IChatSessionData; + }, + findById: async () => null, + } as unknown as IChatGateway; + return { chats, upserts }; +} + +const agentsStub = { findAll: async () => [{ id: 'agent-1' }] } as unknown as IAgentGateway; + +describe('ChatSyncService.syncAll', () => { + it('indexes session files, skips unchanged (by size) and non-session files, and parses names', async () => { + const bridleLive = jsonl( + { id: 'u1', type: 'user', ts: 1, data: { text: 'hi' } }, + { id: 'p', type: 'assistant', ts: 2, data: { text: 'part' } }, + { id: 'c', type: 'user', ts: 3, data: { text: 'Your response was cut off. Continue' } }, + { id: 'a1', type: 'assistant', ts: 4, data: { text: 'part full' } }, + ); + const archived = jsonl({ id: 'x', type: 'user', ts: 9, data: { text: 'old chat' } }); + + const files = [ + { path: 'data/sessions/bridle:admin.jsonl', size: 111 }, + { path: 'data/sessions/telegram:555.jsonl', size: 50 }, // unchanged → skipped + { path: 'data/sessions/bridle:admin.2026-01-01T00-00-00.archived.jsonl', size: 22 }, + { path: 'SOUL.md', size: 10 }, // not a session file + ]; + const contents = { + 'data/sessions/bridle:admin.jsonl': bridleLive, + 'data/sessions/bridle:admin.2026-01-01T00-00-00.archived.jsonl': archived, + }; + + const files_ = fileStub(files, contents); + const reader = new TranscriptReaderService(files_); + const { chats, upserts } = chatsStub([ + { sessionKey: 'telegram:555', lastIndexedSize: 50 }, + ]); + + const svc = new ChatSyncService(files_, reader, chats, agentsStub); + const result = await svc.syncAll('agent-1'); + + expect(result.scannedFiles).toBe(3); // SOUL.md excluded + expect(result.skipped).toBe(1); // telegram:555 unchanged + expect(result.upserted).toBe(2); + + const live = upserts.find((u) => u.sessionKey === 'bridle:admin')!; + expect(live.channel).toBe('bridle'); + expect(live.externalUserId).toBe('admin'); + expect(live.archived).toBe(false); + // hygiene applied: partial + continuation dropped → 1 user + 1 assistant + expect(live.messageCount).toBe(2); + expect(live.userMessageCount).toBe(1); + expect(live.preview).toBe('part full'); + expect(live.lastRole).toBe('assistant'); + + const arch = upserts.find((u) => u.sessionKey.endsWith('.archived'))!; + expect(arch.archived).toBe(true); + expect(arch.channel).toBe('bridle'); + expect(arch.externalUserId).toBe('admin'); + }); +}); diff --git a/api/src/slices/chat/domain/chatSync.service.ts b/api/src/slices/chat/domain/chatSync.service.ts new file mode 100644 index 0000000..b066ea6 --- /dev/null +++ b/api/src/slices/chat/domain/chatSync.service.ts @@ -0,0 +1,172 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { IFileGateway, TranscriptReaderService } from '#/agent/file/domain'; +import { IAgentGateway } from '#/agent/agent/domain/agent.gateway'; +import { IChatGateway } from './chat.gateway'; +import { IChatReconcileInput, IChatSyncResult } from './chat.types'; + +const SESSIONS_PREFIX = 'data/sessions/'; +const PREVIEW_MAX = 200; + +/** + * Reconciles the Postgres chat index against the agent runtimes' JSONL session + * files in S3 (the source of truth for message content). Runs on demand + * (`POST /chats/sync`) and, if `CHAT_SYNC_INTERVAL_SEC` is set, on an interval. + * + * Ranch has no scheduler facility, so we use a plain `setInterval` in an + * OnModuleInit service — the same pattern as agentStatus.service's driftTimer. + */ +@Injectable() +export class ChatSyncService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ChatSyncService.name); + private timer?: ReturnType; + private running = false; + + constructor( + private readonly files: IFileGateway, + private readonly reader: TranscriptReaderService, + private readonly chats: IChatGateway, + private readonly agents: IAgentGateway, + ) {} + + onModuleInit(): void { + const sec = Number(process.env.CHAT_SYNC_INTERVAL_SEC ?? 0); + if (!Number.isFinite(sec) || sec <= 0) return; + this.logger.log(`chat reconcile interval enabled: every ${sec}s`); + this.timer = setInterval(() => { + void this.syncAll().catch((err) => + this.logger.error(`scheduled reconcile failed: ${(err as Error).message}`), + ); + }, sec * 1000); + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer); + } + + /** Reconcile every agent (or just one). Guarded against overlapping runs. */ + async syncAll(agentId?: string): Promise { + if (this.running) { + this.logger.warn('reconcile already in progress — skipping'); + return { scannedAgents: 0, scannedFiles: 0, upserted: 0, skipped: 0 }; + } + this.running = true; + const result: IChatSyncResult = { + scannedAgents: 0, + scannedFiles: 0, + upserted: 0, + skipped: 0, + }; + try { + const agentIds = agentId + ? [agentId] + : (await this.agents.findAll()).map((a) => a.id); + for (const id of agentIds) { + result.scannedAgents++; + await this.syncAgent(id, result); + } + } finally { + this.running = false; + } + this.logger.log( + `reconcile done: agents=${result.scannedAgents} files=${result.scannedFiles} ` + + `upserted=${result.upserted} skipped=${result.skipped}`, + ); + return result; + } + + private async syncAgent(agentId: string, result: IChatSyncResult): Promise { + let nodes; + try { + nodes = await this.files.list(agentId); + } catch (err) { + this.logger.warn(`list failed for ${agentId}: ${(err as Error).message}`); + return; + } + + const sessionFiles = nodes.filter( + (n) => n.path.startsWith(SESSIONS_PREFIX) && n.path.endsWith('.jsonl'), + ); + if (!sessionFiles.length) return; + + // One read of existing rows → size map, so we only re-read changed files. + const existing = await this.chats.list({ + agentId, + includeInternal: true, + perPage: 100_000, + }); + const sizeByKey = new Map( + existing.items.map((s) => [s.sessionKey, s.lastIndexedSize]), + ); + + for (const node of sessionFiles) { + result.scannedFiles++; + const meta = this.parseName(node.path); + if (!meta) continue; + + if (sizeByKey.get(meta.sessionKey) === node.size) { + result.skipped++; + continue; + } + + try { + const input = await this.buildReconcileInput(agentId, node.path, node.size, meta); + await this.chats.reconcileUpsert(input); + result.upserted++; + } catch (err) { + this.logger.warn( + `reconcile ${agentId}/${node.path} failed: ${(err as Error).message}`, + ); + } + } + } + + private async buildReconcileInput( + agentId: string, + path: string, + size: number, + meta: { sessionKey: string; channel: string; externalUserId: string; archived: boolean }, + ): Promise { + const messages = await this.reader.read(agentId, path, { + types: ['user', 'assistant'], + filterTransient: true, + }); + const last = messages[messages.length - 1]; + const userMessageCount = messages.reduce( + (n, m) => (m.role === 'user' ? n + 1 : n), + 0, + ); + return { + agentId, + ...meta, + title: null, + size, + messageCount: messages.length, + userMessageCount, + lastMessageAt: last ? new Date(last.ts) : new Date(0), + preview: last ? last.text.slice(0, PREVIEW_MAX) : null, + lastRole: last ? last.role : null, + }; + } + + // `data/sessions/bridle:admin.jsonl` → { sessionKey:'bridle:admin', channel:'bridle', + // externalUserId:'admin', archived:false }. Archived files are + // `bridle:admin..archived.jsonl` → their own row, archived:true. + private parseName( + path: string, + ): { sessionKey: string; channel: string; externalUserId: string; archived: boolean } | null { + const base = path.slice(SESSIONS_PREFIX.length, -'.jsonl'.length); + const colon = base.indexOf(':'); + if (colon < 0) return null; + const channel = base.slice(0, colon); + const rest = base.slice(colon + 1); + const dot = rest.indexOf('.'); + const externalUserId = dot < 0 ? rest : rest.slice(0, dot); + if (!channel || !externalUserId) return null; + return { + sessionKey: base, + channel, + externalUserId, + archived: base.endsWith('.archived'), + }; + } +} diff --git a/api/src/slices/chat/domain/index.ts b/api/src/slices/chat/domain/index.ts new file mode 100644 index 0000000..7f7e081 --- /dev/null +++ b/api/src/slices/chat/domain/index.ts @@ -0,0 +1,3 @@ +export * from './chat.types'; +export * from './chat.gateway'; +export * from './chatSync.service'; diff --git a/api/src/slices/chat/dtos/chatMessage.dto.ts b/api/src/slices/chat/dtos/chatMessage.dto.ts new file mode 100644 index 0000000..2d0074f --- /dev/null +++ b/api/src/slices/chat/dtos/chatMessage.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ChatMessageDto { + @ApiProperty({ example: 'c94dbcf2-…' }) id: string; + + @ApiProperty({ + enum: ['user', 'assistant', 'summary', 'tool_call', 'tool_result', 'system'], + example: 'assistant', + }) + role: string; + + @ApiProperty({ example: 'Hello, how can I help?' }) text: string; + + @ApiProperty({ example: 1777562539964, description: 'Unix epoch ms' }) ts: number; +} + +export class ChatMessagesResponseDto { + @ApiProperty({ type: [ChatMessageDto] }) messages: ChatMessageDto[]; + + @ApiPropertyOptional({ nullable: true, description: 'Pass to fetch the previous (older) page' }) + nextCursor: string | null; + + @ApiProperty() hasMore: boolean; +} + +export class ChatMessagesQueryDto { + @ApiPropertyOptional({ minimum: 1, maximum: 200, default: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit?: number; + + @ApiPropertyOptional({ description: 'Opaque cursor from a previous page' }) + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ + description: + 'Comma-separated event types (debug toggle). Default user,assistant,summary. ' + + 'Admins may add tool_call,tool_result,system.', + example: 'user,assistant,summary,tool_call,tool_result', + }) + @IsOptional() + @IsString() + types?: string; +} diff --git a/api/src/slices/chat/dtos/chatSession.dto.ts b/api/src/slices/chat/dtos/chatSession.dto.ts new file mode 100644 index 0000000..c8b7fb9 --- /dev/null +++ b/api/src/slices/chat/dtos/chatSession.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ChatSessionDto { + @ApiProperty({ example: 'chat-9f2c…' }) + id: string; + + @ApiProperty() agentId: string; + + @ApiProperty({ enum: ['bridle', 'telegram', 'slack', 'internal'], example: 'bridle' }) + channel: string; + + @ApiProperty({ example: 'admin' }) externalUserId: string; + + @ApiProperty({ example: 'bridle:admin' }) sessionKey: string; + + @ApiPropertyOptional({ nullable: true }) title: string | null; + + @ApiPropertyOptional({ nullable: true, description: 'Last message text, truncated' }) + preview: string | null; + + @ApiPropertyOptional({ enum: ['user', 'assistant'], nullable: true }) + lastRole: string | null; + + @ApiProperty({ description: 'Unix ms via ISO', example: '2026-07-15T09:12:00.000Z' }) + lastMessageAt: Date; + + @ApiProperty({ description: 'Monotonic lifetime total' }) messageCount: number; + + @ApiProperty() userMessageCount: number; + + @ApiPropertyOptional({ nullable: true }) summary: string | null; + + @ApiPropertyOptional({ nullable: true }) summaryAt: Date | null; + + @ApiPropertyOptional({ nullable: true, description: 'topics/sentiment/resolved/language' }) + insights: unknown | null; + + @ApiProperty() archived: boolean; + + @ApiProperty() createdAt: Date; + + @ApiProperty() updatedAt: Date; +} + +export class ChatListResponseDto { + @ApiProperty({ type: [ChatSessionDto] }) items: ChatSessionDto[]; + @ApiProperty({ example: 128 }) total: number; + @ApiProperty({ example: 1 }) page: number; + @ApiProperty({ example: 50 }) perPage: number; +} diff --git a/api/src/slices/chat/dtos/filterChats.dto.ts b/api/src/slices/chat/dtos/filterChats.dto.ts new file mode 100644 index 0000000..c5a0037 --- /dev/null +++ b/api/src/slices/chat/dtos/filterChats.dto.ts @@ -0,0 +1,49 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +const toBool = ({ value }: { value: unknown }) => value === true || value === 'true'; + +export class FilterChatsDto { + @ApiPropertyOptional({ description: 'Restrict to one agent' }) + @IsOptional() + @IsString() + agentId?: string; + + @ApiPropertyOptional({ enum: ['bridle', 'telegram', 'slack', 'internal'] }) + @IsOptional() + @IsString() + channel?: string; + + @ApiPropertyOptional({ description: 'Matches title / preview / externalUserId' }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ default: false, description: 'Show archived sessions' }) + @IsOptional() + @Transform(toBool) + @IsBoolean() + archived?: boolean; + + @ApiPropertyOptional({ default: false, description: 'Include internal (cron/heartbeat) sessions' }) + @IsOptional() + @Transform(toBool) + @IsBoolean() + includeInternal?: boolean; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + perPage?: number; +} diff --git a/api/src/slices/chat/dtos/index.ts b/api/src/slices/chat/dtos/index.ts new file mode 100644 index 0000000..008e8f6 --- /dev/null +++ b/api/src/slices/chat/dtos/index.ts @@ -0,0 +1,4 @@ +export * from './chatSession.dto'; +export * from './filterChats.dto'; +export * from './chatMessage.dto'; +export * from './syncChats.dto'; diff --git a/api/src/slices/chat/dtos/syncChats.dto.ts b/api/src/slices/chat/dtos/syncChats.dto.ts new file mode 100644 index 0000000..697ec7f --- /dev/null +++ b/api/src/slices/chat/dtos/syncChats.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class SyncChatsDto { + @ApiPropertyOptional({ description: 'Reconcile only this agent; omit for all agents' }) + @IsOptional() + @IsString() + agentId?: string; +} + +export class SyncChatsResponseDto { + @ApiProperty() scannedAgents: number; + @ApiProperty() scannedFiles: number; + @ApiProperty() upserted: number; + @ApiProperty() skipped: number; +} From 285c5619654cdee27d9c6de9899b8f8abaa13b9c Mon Sep 17 00:00:00 2001 From: maksym Date: Wed, 15 Jul 2026 18:20:47 +0200 Subject: [PATCH 02/21] feat(chat): admin chat-history UI (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin slice to browse the Phase 1 chat index: a filterable list, a chat detail with a tail-first transcript, and a "Chats" tab on the agent page. - slices/chat: Pinia store (ChatsService + {success,data} unwrap); chats list page + ChatListProvider (search / channel / archived / internal filters, server pagination, Sync button, agent-scopable via :agent-id); chat detail + ChatDetailProvider (meta header, scroll-up "load older" paging, "show tool events" debug toggle); read-only ChatMessageBubble (assistant markdown via bridle's renderMarkdown, collapsible summary marker, compact tool_call/tool_result blocks). - Sidebar "Chats" nav item (+ MessageSquare in iconMap). - Agent detail: "Chats" tab rendering . - Regenerate admin + app OpenAPI clients from the new chat endpoints (ChatsService: getChats/getChat/getChatMessages/syncChats) — additive only. Verified: nuxt build green, store tsc clean. Co-Authored-By: Claude Opus 4.8 --- .../agent/agent/components/agent/Provider.vue | 7 +- .../chat/components/chatDetail/Provider.vue | 134 ++++++++++++ .../chat/components/chatList/Provider.vue | 176 ++++++++++++++++ .../chat/components/chatMessage/Bubble.vue | 105 ++++++++++ admin/slices/chat/nuxt.config.ts | 9 + admin/slices/chat/pages/chats/[id].vue | 8 + admin/slices/chat/pages/chats/index.vue | 13 ++ admin/slices/chat/plugins/menu.ts | 13 ++ admin/slices/chat/stores/chat.ts | 125 +++++++++++ .../common/components/layout/Sidebar.vue | 3 +- .../api/data/repositories/api/schemas.gen.ts | 197 ++++++++++++++++++ .../api/data/repositories/api/sdk.gen.ts | 78 +++++++ .../api/data/repositories/api/types.gen.ts | 177 ++++++++++++++++ .../api/data/repositories/api/schemas.gen.ts | 197 ++++++++++++++++++ .../api/data/repositories/api/sdk.gen.ts | 78 +++++++ .../api/data/repositories/api/types.gen.ts | 177 ++++++++++++++++ 16 files changed, 1495 insertions(+), 2 deletions(-) create mode 100644 admin/slices/chat/components/chatDetail/Provider.vue create mode 100644 admin/slices/chat/components/chatList/Provider.vue create mode 100644 admin/slices/chat/components/chatMessage/Bubble.vue create mode 100644 admin/slices/chat/nuxt.config.ts create mode 100644 admin/slices/chat/pages/chats/[id].vue create mode 100644 admin/slices/chat/pages/chats/index.vue create mode 100644 admin/slices/chat/plugins/menu.ts create mode 100644 admin/slices/chat/stores/chat.ts diff --git a/admin/slices/agent/agent/components/agent/Provider.vue b/admin/slices/agent/agent/components/agent/Provider.vue index cb40f68..4bb7564 100644 --- a/admin/slices/agent/agent/components/agent/Provider.vue +++ b/admin/slices/agent/agent/components/agent/Provider.vue @@ -503,7 +503,7 @@ async function onPaddockEvalStarted() { // Tab state — persisted in the URL so deep links + browser back work. // `chat` is the default since 99% of the time the user is here to talk to the // agent, not to inspect its plumbing. -const TABS = ['chat', 'overview', 'knowledge', 'files', 'secrets', 'env', 'channels', 'logs', 'paddock'] as const; +const TABS = ['chat', 'overview', 'knowledge', 'files', 'secrets', 'env', 'channels', 'chats', 'logs', 'paddock'] as const; type AgentTab = (typeof TABS)[number]; const route = useRoute(); const router = useRouter(); @@ -693,6 +693,7 @@ onBeforeUnmount(stopMetricsPolling); { value: 'secrets', title: 'Secrets', desc: 'User-scoped secrets the runtime stores.' }, { value: 'env', title: 'Environment', desc: 'Env vars injected at deploy time.' }, { value: 'channels', title: 'Channels', desc: 'Messaging platforms (Telegram, …) the agent talks on.' }, + { value: 'chats', title: 'Chats', desc: 'Conversation history across channels.' }, { value: 'logs', title: 'Logs', desc: 'Pod logs from the runtime container.' }, { value: 'paddock', title: 'Paddock', desc: 'Run evaluations & manage scenarios.' }, ]" @@ -1381,6 +1382,10 @@ onBeforeUnmount(stopMetricsPolling); + + + + diff --git a/admin/slices/chat/components/chatDetail/Provider.vue b/admin/slices/chat/components/chatDetail/Provider.vue new file mode 100644 index 0000000..ee27190 --- /dev/null +++ b/admin/slices/chat/components/chatDetail/Provider.vue @@ -0,0 +1,134 @@ + + + diff --git a/admin/slices/chat/components/chatList/Provider.vue b/admin/slices/chat/components/chatList/Provider.vue new file mode 100644 index 0000000..2717843 --- /dev/null +++ b/admin/slices/chat/components/chatList/Provider.vue @@ -0,0 +1,176 @@ + + + diff --git a/admin/slices/chat/components/chatMessage/Bubble.vue b/admin/slices/chat/components/chatMessage/Bubble.vue new file mode 100644 index 0000000..396c0e6 --- /dev/null +++ b/admin/slices/chat/components/chatMessage/Bubble.vue @@ -0,0 +1,105 @@ + + + diff --git a/admin/slices/chat/nuxt.config.ts b/admin/slices/chat/nuxt.config.ts new file mode 100644 index 0000000..c640719 --- /dev/null +++ b/admin/slices/chat/nuxt.config.ts @@ -0,0 +1,9 @@ +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); + +export default defineNuxtConfig({ + alias: { '#chat': currentDir }, + imports: { dirs: [`${currentDir}/stores`] }, +}); diff --git a/admin/slices/chat/pages/chats/[id].vue b/admin/slices/chat/pages/chats/[id].vue new file mode 100644 index 0000000..adceea6 --- /dev/null +++ b/admin/slices/chat/pages/chats/[id].vue @@ -0,0 +1,8 @@ + + + diff --git a/admin/slices/chat/pages/chats/index.vue b/admin/slices/chat/pages/chats/index.vue new file mode 100644 index 0000000..ba82f04 --- /dev/null +++ b/admin/slices/chat/pages/chats/index.vue @@ -0,0 +1,13 @@ + + + diff --git a/admin/slices/chat/plugins/menu.ts b/admin/slices/chat/plugins/menu.ts new file mode 100644 index 0000000..96820a3 --- /dev/null +++ b/admin/slices/chat/plugins/menu.ts @@ -0,0 +1,13 @@ +import { MenuGroupTypes, useMenuStore } from '#common/stores/menu'; + +export default defineNuxtPlugin(() => { + useMenuStore().addSidebar({ + id: 'chat', + group: MenuGroupTypes.Main, + title: 'Chats', + link: 'chats', // route name of slices/chat/pages/chats/index.vue + active: false, + icon: 'MessageSquare', + sortOrder: 35, // between Knowledges (30) and Evaluations (40) + }); +}); diff --git a/admin/slices/chat/stores/chat.ts b/admin/slices/chat/stores/chat.ts new file mode 100644 index 0000000..f45fb1b --- /dev/null +++ b/admin/slices/chat/stores/chat.ts @@ -0,0 +1,125 @@ +import { ChatsService } from '#api/data'; + +// The API wraps every response in { success, data }; unwrap to the payload. +type ApiEnvelope = { success: boolean; data: T }; +function unwrap(body: unknown): T | null { + if (body && typeof body === 'object' && 'data' in (body as ApiEnvelope)) { + return ((body as ApiEnvelope).data ?? null) as T | null; + } + return (body ?? null) as T | null; +} + +export type ChatChannel = 'bridle' | 'telegram' | 'slack' | 'internal'; + +export interface IChatSession { + id: string; + agentId: string; + channel: string; + externalUserId: string; + sessionKey: string; + title: string | null; + preview: string | null; + lastRole: string | null; + lastMessageAt: string; + messageCount: number; + userMessageCount: number; + summary: string | null; + summaryAt: string | null; + insights: unknown | null; + archived: boolean; + createdAt: string; + updatedAt: string; +} + +export interface IChatListResult { + items: IChatSession[]; + total: number; + page: number; + perPage: number; +} + +export type ChatMessageRole = + | 'user' + | 'assistant' + | 'summary' + | 'tool_call' + | 'tool_result' + | 'system'; + +export interface IChatMessage { + id: string; + role: ChatMessageRole; + text: string; + ts: number; +} + +export interface IChatMessagesResult { + messages: IChatMessage[]; + nextCursor: string | null; + hasMore: boolean; +} + +export interface IChatListQuery { + agentId?: string; + channel?: ChatChannel; + search?: string; + archived?: boolean; + includeInternal?: boolean; + page?: number; + perPage?: number; +} + +export interface IChatMessagesQuery { + limit?: number; + cursor?: string; + types?: string; // comma-separated event types (tool debug toggle) +} + +export interface IChatSyncResult { + scannedAgents: number; + scannedFiles: number; + upserted: number; + skipped: number; +} + +export const useChatStore = defineStore('chat', () => { + async function list(query: IChatListQuery = {}): Promise { + const res = await ChatsService.getChats({ query }); + return ( + unwrap(res.data) ?? { + items: [], + total: 0, + page: query.page ?? 1, + perPage: query.perPage ?? 50, + } + ); + } + + async function getById(id: string): Promise { + const res = await ChatsService.getChat({ path: { id } }); + return unwrap(res.data); + } + + async function messages( + id: string, + query: IChatMessagesQuery = {}, + ): Promise { + const res = await ChatsService.getChatMessages({ path: { id }, query }); + return ( + unwrap(res.data) ?? { + messages: [], + nextCursor: null, + hasMore: false, + } + ); + } + + async function sync(agentId?: string): Promise { + const res = await ChatsService.syncChats({ + body: agentId ? { agentId } : {}, + }); + return unwrap(res.data); + } + + return { list, getById, messages, sync }; +}); diff --git a/admin/slices/common/components/layout/Sidebar.vue b/admin/slices/common/components/layout/Sidebar.vue index 16a4efe..b4a4550 100644 --- a/admin/slices/common/components/layout/Sidebar.vue +++ b/admin/slices/common/components/layout/Sidebar.vue @@ -36,7 +36,7 @@ import { IconExternalLink, IconBrowser, } from '@tabler/icons-vue'; -import { Bot, FlaskConical, KeyRound } from 'lucide-vue-next'; +import { Bot, FlaskConical, KeyRound, MessageSquare } from 'lucide-vue-next'; const authStore = useAuthStore(); const update = useRanchUpdate(); @@ -65,6 +65,7 @@ const iconMap: Record = { Plug: IconPlug, FlaskConical, KeyRound, + MessageSquare, Browser: IconBrowser, }; diff --git a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts index b860737..f7a6c1d 100644 --- a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts @@ -1919,6 +1919,203 @@ export const ReportUsageDtoSchema = { required: ["date", "byModel"], } as const; +export const ChatSessionDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + example: "chat-9f2c…", + }, + agentId: { + type: "string", + }, + channel: { + type: "string", + enum: ["bridle", "telegram", "slack", "internal"], + example: "bridle", + }, + externalUserId: { + type: "string", + example: "admin", + }, + sessionKey: { + type: "string", + example: "bridle:admin", + }, + title: { + type: "object", + nullable: true, + }, + preview: { + type: "object", + nullable: true, + description: "Last message text, truncated", + }, + lastRole: { + type: "string", + enum: ["user", "assistant"], + nullable: true, + }, + lastMessageAt: { + format: "date-time", + type: "string", + description: "Unix ms via ISO", + example: "2026-07-15T09:12:00.000Z", + }, + messageCount: { + type: "number", + description: "Monotonic lifetime total", + }, + userMessageCount: { + type: "number", + }, + summary: { + type: "object", + nullable: true, + }, + summaryAt: { + type: "object", + nullable: true, + }, + insights: { + type: "object", + nullable: true, + description: "topics/sentiment/resolved/language", + }, + archived: { + type: "boolean", + }, + createdAt: { + format: "date-time", + type: "string", + }, + updatedAt: { + format: "date-time", + type: "string", + }, + }, + required: [ + "id", + "agentId", + "channel", + "externalUserId", + "sessionKey", + "lastMessageAt", + "messageCount", + "userMessageCount", + "archived", + "createdAt", + "updatedAt", + ], +} as const; + +export const ChatListResponseDtoSchema = { + type: "object", + properties: { + items: { + type: "array", + items: { + $ref: "#/components/schemas/ChatSessionDto", + }, + }, + total: { + type: "number", + example: 128, + }, + page: { + type: "number", + example: 1, + }, + perPage: { + type: "number", + example: 50, + }, + }, + required: ["items", "total", "page", "perPage"], +} as const; + +export const ChatMessageDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + example: "c94dbcf2-…", + }, + role: { + type: "string", + enum: [ + "user", + "assistant", + "summary", + "tool_call", + "tool_result", + "system", + ], + example: "assistant", + }, + text: { + type: "string", + example: "Hello, how can I help?", + }, + ts: { + type: "number", + example: 1777562539964, + description: "Unix epoch ms", + }, + }, + required: ["id", "role", "text", "ts"], +} as const; + +export const ChatMessagesResponseDtoSchema = { + type: "object", + properties: { + messages: { + type: "array", + items: { + $ref: "#/components/schemas/ChatMessageDto", + }, + }, + nextCursor: { + type: "object", + nullable: true, + description: "Pass to fetch the previous (older) page", + }, + hasMore: { + type: "boolean", + }, + }, + required: ["messages", "hasMore"], +} as const; + +export const SyncChatsDtoSchema = { + type: "object", + properties: { + agentId: { + type: "string", + description: "Reconcile only this agent; omit for all agents", + }, + }, +} as const; + +export const SyncChatsResponseDtoSchema = { + type: "object", + properties: { + scannedAgents: { + type: "number", + }, + scannedFiles: { + type: "number", + }, + upserted: { + type: "number", + }, + skipped: { + type: "number", + }, + }, + required: ["scannedAgents", "scannedFiles", "upserted", "skipped"], +} as const; + export const RunPaddockJudgeOverrideDtoSchema = { type: "object", properties: { diff --git a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts index dedbb3f..b278e52 100644 --- a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -157,6 +157,14 @@ import type { UsageControllerReportData, UsageControllerReportResponse, UsageControllerFindForCredentialData, + GetChatsData, + GetChatsResponse, + GetChatData, + GetChatResponse, + GetChatMessagesData, + GetChatMessagesResponse, + SyncChatsData, + SyncChatsResponse, RancherControllerStatusData, RancherControllerEnsureTemplateData, UpgradeControllerStatusData, @@ -2405,6 +2413,76 @@ export class UsageService { } } +export class ChatsService { + /** + * List chat sessions (index). Filter by agent, channel, search; paginated. + */ + public static getChats( + options?: Options, + ) { + return (options?.client ?? _heyApiClient).get< + GetChatsResponse, + unknown, + ThrowOnError + >({ + url: "/chats", + ...options, + }); + } + + /** + * Get one chat session (index metadata). + */ + public static getChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetChatResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}", + ...options, + }); + } + + /** + * Replay a chat session's transcript from S3, tail-first. `summary` markers are shown inline (compaction folds old turns into them); synthetic loop-control events are filtered. Admins may add tool_call,tool_result via `types`. + */ + public static getChatMessages( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetChatMessagesResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/messages", + ...options, + }); + } + + /** + * Reconcile the chat index against S3 session files (all agents, or one). + */ + public static syncChats( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + SyncChatsResponse, + unknown, + ThrowOnError + >({ + url: "/chats/sync", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } +} + export class RancherService { /** * Stepper state for the Rancher setup wizard: do we have an LLM, the special template, and an admin agent? diff --git a/admin/slices/setup/api/data/repositories/api/types.gen.ts b/admin/slices/setup/api/data/repositories/api/types.gen.ts index 12b0b7a..be7e46d 100644 --- a/admin/slices/setup/api/data/repositories/api/types.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/types.gen.ts @@ -838,6 +838,96 @@ export type ReportUsageDto = { }; }; +export type ChatSessionDto = { + id: string; + agentId: string; + channel: "bridle" | "telegram" | "slack" | "internal"; + externalUserId: string; + sessionKey: string; + title?: { + [key: string]: unknown; + } | null; + /** + * Last message text, truncated + */ + preview?: { + [key: string]: unknown; + } | null; + lastRole?: "user" | "assistant"; + /** + * Unix ms via ISO + */ + lastMessageAt: string; + /** + * Monotonic lifetime total + */ + messageCount: number; + userMessageCount: number; + summary?: { + [key: string]: unknown; + } | null; + summaryAt?: { + [key: string]: unknown; + } | null; + /** + * topics/sentiment/resolved/language + */ + insights?: { + [key: string]: unknown; + } | null; + archived: boolean; + createdAt: string; + updatedAt: string; +}; + +export type ChatListResponseDto = { + items: Array; + total: number; + page: number; + perPage: number; +}; + +export type ChatMessageDto = { + id: string; + role: + | "user" + | "assistant" + | "summary" + | "tool_call" + | "tool_result" + | "system"; + text: string; + /** + * Unix epoch ms + */ + ts: number; +}; + +export type ChatMessagesResponseDto = { + messages: Array; + /** + * Pass to fetch the previous (older) page + */ + nextCursor?: { + [key: string]: unknown; + } | null; + hasMore: boolean; +}; + +export type SyncChatsDto = { + /** + * Reconcile only this agent; omit for all agents + */ + agentId?: string; +}; + +export type SyncChatsResponseDto = { + scannedAgents: number; + scannedFiles: number; + upserted: number; + skipped: number; +}; + export type RunPaddockJudgeOverrideDto = { credentialIds?: Array; threshold?: number; @@ -2845,6 +2935,93 @@ export type UsageControllerFindForCredentialResponses = { 200: unknown; }; +export type GetChatsData = { + body?: never; + path?: never; + query?: { + /** + * Restrict to one agent + */ + agentId?: string; + channel?: "bridle" | "telegram" | "slack" | "internal"; + /** + * Matches title / preview / externalUserId + */ + search?: string; + /** + * Show archived sessions + */ + archived?: boolean; + /** + * Include internal (cron/heartbeat) sessions + */ + includeInternal?: boolean; + page?: number; + perPage?: number; + }; + url: "/chats"; +}; + +export type GetChatsResponses = { + 200: ChatListResponseDto; +}; + +export type GetChatsResponse = GetChatsResponses[keyof GetChatsResponses]; + +export type GetChatData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}"; +}; + +export type GetChatResponses = { + 200: ChatSessionDto; +}; + +export type GetChatResponse = GetChatResponses[keyof GetChatResponses]; + +export type GetChatMessagesData = { + body?: never; + path: { + id: string; + }; + query?: { + limit?: number; + /** + * Opaque cursor from a previous page + */ + cursor?: string; + /** + * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. + */ + types?: string; + }; + url: "/chats/{id}/messages"; +}; + +export type GetChatMessagesResponses = { + 200: ChatMessagesResponseDto; +}; + +export type GetChatMessagesResponse = + GetChatMessagesResponses[keyof GetChatMessagesResponses]; + +export type SyncChatsData = { + body: SyncChatsDto; + path?: never; + query?: never; + url: "/chats/sync"; +}; + +export type SyncChatsResponses = { + 200: SyncChatsResponseDto; +}; + +export type SyncChatsResponse = SyncChatsResponses[keyof SyncChatsResponses]; + export type RancherControllerStatusData = { body?: never; path?: never; diff --git a/app/slices/setup/api/data/repositories/api/schemas.gen.ts b/app/slices/setup/api/data/repositories/api/schemas.gen.ts index b860737..f7a6c1d 100644 --- a/app/slices/setup/api/data/repositories/api/schemas.gen.ts +++ b/app/slices/setup/api/data/repositories/api/schemas.gen.ts @@ -1919,6 +1919,203 @@ export const ReportUsageDtoSchema = { required: ["date", "byModel"], } as const; +export const ChatSessionDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + example: "chat-9f2c…", + }, + agentId: { + type: "string", + }, + channel: { + type: "string", + enum: ["bridle", "telegram", "slack", "internal"], + example: "bridle", + }, + externalUserId: { + type: "string", + example: "admin", + }, + sessionKey: { + type: "string", + example: "bridle:admin", + }, + title: { + type: "object", + nullable: true, + }, + preview: { + type: "object", + nullable: true, + description: "Last message text, truncated", + }, + lastRole: { + type: "string", + enum: ["user", "assistant"], + nullable: true, + }, + lastMessageAt: { + format: "date-time", + type: "string", + description: "Unix ms via ISO", + example: "2026-07-15T09:12:00.000Z", + }, + messageCount: { + type: "number", + description: "Monotonic lifetime total", + }, + userMessageCount: { + type: "number", + }, + summary: { + type: "object", + nullable: true, + }, + summaryAt: { + type: "object", + nullable: true, + }, + insights: { + type: "object", + nullable: true, + description: "topics/sentiment/resolved/language", + }, + archived: { + type: "boolean", + }, + createdAt: { + format: "date-time", + type: "string", + }, + updatedAt: { + format: "date-time", + type: "string", + }, + }, + required: [ + "id", + "agentId", + "channel", + "externalUserId", + "sessionKey", + "lastMessageAt", + "messageCount", + "userMessageCount", + "archived", + "createdAt", + "updatedAt", + ], +} as const; + +export const ChatListResponseDtoSchema = { + type: "object", + properties: { + items: { + type: "array", + items: { + $ref: "#/components/schemas/ChatSessionDto", + }, + }, + total: { + type: "number", + example: 128, + }, + page: { + type: "number", + example: 1, + }, + perPage: { + type: "number", + example: 50, + }, + }, + required: ["items", "total", "page", "perPage"], +} as const; + +export const ChatMessageDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + example: "c94dbcf2-…", + }, + role: { + type: "string", + enum: [ + "user", + "assistant", + "summary", + "tool_call", + "tool_result", + "system", + ], + example: "assistant", + }, + text: { + type: "string", + example: "Hello, how can I help?", + }, + ts: { + type: "number", + example: 1777562539964, + description: "Unix epoch ms", + }, + }, + required: ["id", "role", "text", "ts"], +} as const; + +export const ChatMessagesResponseDtoSchema = { + type: "object", + properties: { + messages: { + type: "array", + items: { + $ref: "#/components/schemas/ChatMessageDto", + }, + }, + nextCursor: { + type: "object", + nullable: true, + description: "Pass to fetch the previous (older) page", + }, + hasMore: { + type: "boolean", + }, + }, + required: ["messages", "hasMore"], +} as const; + +export const SyncChatsDtoSchema = { + type: "object", + properties: { + agentId: { + type: "string", + description: "Reconcile only this agent; omit for all agents", + }, + }, +} as const; + +export const SyncChatsResponseDtoSchema = { + type: "object", + properties: { + scannedAgents: { + type: "number", + }, + scannedFiles: { + type: "number", + }, + upserted: { + type: "number", + }, + skipped: { + type: "number", + }, + }, + required: ["scannedAgents", "scannedFiles", "upserted", "skipped"], +} as const; + export const RunPaddockJudgeOverrideDtoSchema = { type: "object", properties: { diff --git a/app/slices/setup/api/data/repositories/api/sdk.gen.ts b/app/slices/setup/api/data/repositories/api/sdk.gen.ts index dedbb3f..b278e52 100644 --- a/app/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/app/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -157,6 +157,14 @@ import type { UsageControllerReportData, UsageControllerReportResponse, UsageControllerFindForCredentialData, + GetChatsData, + GetChatsResponse, + GetChatData, + GetChatResponse, + GetChatMessagesData, + GetChatMessagesResponse, + SyncChatsData, + SyncChatsResponse, RancherControllerStatusData, RancherControllerEnsureTemplateData, UpgradeControllerStatusData, @@ -2405,6 +2413,76 @@ export class UsageService { } } +export class ChatsService { + /** + * List chat sessions (index). Filter by agent, channel, search; paginated. + */ + public static getChats( + options?: Options, + ) { + return (options?.client ?? _heyApiClient).get< + GetChatsResponse, + unknown, + ThrowOnError + >({ + url: "/chats", + ...options, + }); + } + + /** + * Get one chat session (index metadata). + */ + public static getChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetChatResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}", + ...options, + }); + } + + /** + * Replay a chat session's transcript from S3, tail-first. `summary` markers are shown inline (compaction folds old turns into them); synthetic loop-control events are filtered. Admins may add tool_call,tool_result via `types`. + */ + public static getChatMessages( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetChatMessagesResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/messages", + ...options, + }); + } + + /** + * Reconcile the chat index against S3 session files (all agents, or one). + */ + public static syncChats( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + SyncChatsResponse, + unknown, + ThrowOnError + >({ + url: "/chats/sync", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } +} + export class RancherService { /** * Stepper state for the Rancher setup wizard: do we have an LLM, the special template, and an admin agent? diff --git a/app/slices/setup/api/data/repositories/api/types.gen.ts b/app/slices/setup/api/data/repositories/api/types.gen.ts index 12b0b7a..be7e46d 100644 --- a/app/slices/setup/api/data/repositories/api/types.gen.ts +++ b/app/slices/setup/api/data/repositories/api/types.gen.ts @@ -838,6 +838,96 @@ export type ReportUsageDto = { }; }; +export type ChatSessionDto = { + id: string; + agentId: string; + channel: "bridle" | "telegram" | "slack" | "internal"; + externalUserId: string; + sessionKey: string; + title?: { + [key: string]: unknown; + } | null; + /** + * Last message text, truncated + */ + preview?: { + [key: string]: unknown; + } | null; + lastRole?: "user" | "assistant"; + /** + * Unix ms via ISO + */ + lastMessageAt: string; + /** + * Monotonic lifetime total + */ + messageCount: number; + userMessageCount: number; + summary?: { + [key: string]: unknown; + } | null; + summaryAt?: { + [key: string]: unknown; + } | null; + /** + * topics/sentiment/resolved/language + */ + insights?: { + [key: string]: unknown; + } | null; + archived: boolean; + createdAt: string; + updatedAt: string; +}; + +export type ChatListResponseDto = { + items: Array; + total: number; + page: number; + perPage: number; +}; + +export type ChatMessageDto = { + id: string; + role: + | "user" + | "assistant" + | "summary" + | "tool_call" + | "tool_result" + | "system"; + text: string; + /** + * Unix epoch ms + */ + ts: number; +}; + +export type ChatMessagesResponseDto = { + messages: Array; + /** + * Pass to fetch the previous (older) page + */ + nextCursor?: { + [key: string]: unknown; + } | null; + hasMore: boolean; +}; + +export type SyncChatsDto = { + /** + * Reconcile only this agent; omit for all agents + */ + agentId?: string; +}; + +export type SyncChatsResponseDto = { + scannedAgents: number; + scannedFiles: number; + upserted: number; + skipped: number; +}; + export type RunPaddockJudgeOverrideDto = { credentialIds?: Array; threshold?: number; @@ -2845,6 +2935,93 @@ export type UsageControllerFindForCredentialResponses = { 200: unknown; }; +export type GetChatsData = { + body?: never; + path?: never; + query?: { + /** + * Restrict to one agent + */ + agentId?: string; + channel?: "bridle" | "telegram" | "slack" | "internal"; + /** + * Matches title / preview / externalUserId + */ + search?: string; + /** + * Show archived sessions + */ + archived?: boolean; + /** + * Include internal (cron/heartbeat) sessions + */ + includeInternal?: boolean; + page?: number; + perPage?: number; + }; + url: "/chats"; +}; + +export type GetChatsResponses = { + 200: ChatListResponseDto; +}; + +export type GetChatsResponse = GetChatsResponses[keyof GetChatsResponses]; + +export type GetChatData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}"; +}; + +export type GetChatResponses = { + 200: ChatSessionDto; +}; + +export type GetChatResponse = GetChatResponses[keyof GetChatResponses]; + +export type GetChatMessagesData = { + body?: never; + path: { + id: string; + }; + query?: { + limit?: number; + /** + * Opaque cursor from a previous page + */ + cursor?: string; + /** + * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. + */ + types?: string; + }; + url: "/chats/{id}/messages"; +}; + +export type GetChatMessagesResponses = { + 200: ChatMessagesResponseDto; +}; + +export type GetChatMessagesResponse = + GetChatMessagesResponses[keyof GetChatMessagesResponses]; + +export type SyncChatsData = { + body: SyncChatsDto; + path?: never; + query?: never; + url: "/chats/sync"; +}; + +export type SyncChatsResponses = { + 200: SyncChatsResponseDto; +}; + +export type SyncChatsResponse = SyncChatsResponses[keyof SyncChatsResponses]; + export type RancherControllerStatusData = { body?: never; path?: never; From 59604e7f1796c18a0973b69a6de5b61b81e5d10c Mon Sep 17 00:00:00 2001 From: maksym Date: Wed, 15 Jul 2026 19:05:44 +0200 Subject: [PATCH 03/21] feat(chat): realtime chat-index updates via session_activity (Phase 3) The hub records every session_activity signal from the agent runtime into the chat index, so the admin list updates live (no reconcile wait). - IChatGateway.recordActivity: atomic increment via updateMany (race-free, no read-modify-write), dedup by eventId (NOT-filter), create fallback with a retry on the concurrent-create unique violation. Realtime owns the monotonic counts. - bridleAgentWs.handler subscribes to `session_activity` -> recordActivity, taking agentId from the authenticated socket (never the payload). - BridleModule imports ChatModule; ChatModule forwardRefs File/AgentModule to break the Bridle -> Chat -> File -> Bridle DI cycle. - recordActivity specs (create / increment / dedup / create-race). Co-Authored-By: Claude Opus 4.8 --- api/src/slices/bridle/bridle.module.ts | 2 + .../bridle/handlers/bridleAgentWs.handler.ts | 20 +++++ api/src/slices/chat/chat.module.ts | 6 +- api/src/slices/chat/data/chat.gateway.spec.ts | 79 ++++++++++++++++++- api/src/slices/chat/data/chat.gateway.ts | 39 +++++++++ api/src/slices/chat/data/chat.mapper.ts | 21 ++++- api/src/slices/chat/domain/chat.gateway.ts | 8 ++ api/src/slices/chat/domain/chat.types.ts | 15 ++++ 8 files changed, 186 insertions(+), 4 deletions(-) diff --git a/api/src/slices/bridle/bridle.module.ts b/api/src/slices/bridle/bridle.module.ts index a44fa8c..e77a4ab 100644 --- a/api/src/slices/bridle/bridle.module.ts +++ b/api/src/slices/bridle/bridle.module.ts @@ -8,6 +8,7 @@ import { BridleGateway } from './data'; import { BridleApiKeyGuard } from './guards/bridleApiKey.guard'; import { FileModule } from '#/agent/file/file.module'; import { AgentModule } from '#/agent/agent/agent.module'; +import { ChatModule } from '#/chat/chat.module'; /** * Bridle Module — authenticated hub between browsers and agents. @@ -46,6 +47,7 @@ import { AgentModule } from '#/agent/agent/agent.module'; ConfigModule, forwardRef(() => FileModule), forwardRef(() => AgentModule), + forwardRef(() => ChatModule), JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ diff --git a/api/src/slices/bridle/handlers/bridleAgentWs.handler.ts b/api/src/slices/bridle/handlers/bridleAgentWs.handler.ts index 51e95de..62c0168 100644 --- a/api/src/slices/bridle/handlers/bridleAgentWs.handler.ts +++ b/api/src/slices/bridle/handlers/bridleAgentWs.handler.ts @@ -16,6 +16,7 @@ import { type IBridleDebugEvent, } from '../domain'; import { IAgentGateway } from '#/agent/agent/domain/agent.gateway'; +import { IChatGateway, type IChatActivity } from '#/chat/domain'; /** * WebSocket gateway for AGENT runtime connections. @@ -51,6 +52,7 @@ export class BridleAgentWsHandler private readonly config: ConfigService, @Inject(forwardRef(() => IAgentGateway)) private readonly agentGateway: IAgentGateway, + private readonly chats: IChatGateway, ) {} handleConnection(client: Socket) { @@ -165,6 +167,24 @@ export class BridleAgentWsHandler } } + @SubscribeMessage('session_activity') + handleSessionActivity( + @ConnectedSocket() client: Socket, + @MessageBody() data: IChatActivity, + ) { + // agentId comes from the authenticated socket — never trusted from payload. + const agentId = client.data?.agentId as string; + if (!agentId || !data?.sessionKey) return; + if (data.role !== 'user' && data.role !== 'assistant') return; + void this.chats + .recordActivity(agentId, data) + .catch((err: Error) => + this.logger.warn( + `session_activity record failed for ${agentId}: ${err.message}`, + ), + ); + } + @SubscribeMessage('debug') handleDebug( @ConnectedSocket() client: Socket, diff --git a/api/src/slices/chat/chat.module.ts b/api/src/slices/chat/chat.module.ts index 325226e..c436f79 100644 --- a/api/src/slices/chat/chat.module.ts +++ b/api/src/slices/chat/chat.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { FileModule } from '#/agent/file/file.module'; import { AgentModule } from '#/agent/agent/agent.module'; import { ChatController } from './chat.controller'; @@ -7,7 +7,9 @@ import { ChatGateway } from './data/chat.gateway'; import { ChatMapper } from './data/chat.mapper'; @Module({ - imports: [FileModule, AgentModule], + // forwardRef because BridleModule now imports ChatModule, forming the cycle + // Bridle → Chat → File → Bridle (File already forwardRefs Bridle). + imports: [forwardRef(() => FileModule), forwardRef(() => AgentModule)], controllers: [ChatController], providers: [ ChatMapper, diff --git a/api/src/slices/chat/data/chat.gateway.spec.ts b/api/src/slices/chat/data/chat.gateway.spec.ts index ec4d34b..b398634 100644 --- a/api/src/slices/chat/data/chat.gateway.spec.ts +++ b/api/src/slices/chat/data/chat.gateway.spec.ts @@ -1,6 +1,6 @@ import { ChatGateway } from './chat.gateway'; import { ChatMapper } from './chat.mapper'; -import { IChatReconcileInput } from '../domain'; +import { IChatActivity, IChatReconcileInput } from '../domain'; // In-memory Prisma stub — mirrors ranch's browser.gateway.spec pattern. function makePrismaStub() { @@ -31,6 +31,14 @@ function makePrismaStub() { return null; }), create: jest.fn(async ({ data }: { data: Record }) => { + // Enforce @@unique([agentId, sessionKey]) so recordActivity's create-race + // fallback is exercised. + const clash = Object.values(rows).find( + (r) => r.agentId === data.agentId && r.sessionKey === data.sessionKey, + ); + if (clash) { + throw Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }); + } rows[data.id as string] = { summary: null, summaryAt: null, @@ -47,6 +55,25 @@ function makePrismaStub() { rows[where.id] = { ...rows[where.id], ...data }; return rows[where.id]; }), + updateMany: jest.fn( + async ({ where, data }: { where: Record; data: Record }) => { + let count = 0; + for (const r of Object.values(rows)) { + if (where.agentId && r.agentId !== where.agentId) continue; + if (where.sessionKey && r.sessionKey !== where.sessionKey) continue; + if (where.NOT?.lastIndexedEventId !== undefined && r.lastIndexedEventId === where.NOT.lastIndexedEventId) continue; + for (const [k, v] of Object.entries(data)) { + if (v && typeof v === 'object' && 'increment' in v) { + r[k] = ((r[k] as number) ?? 0) + (v.increment as number); + } else { + r[k] = v; + } + } + count++; + } + return { count }; + }, + ), findMany: jest.fn( async ({ where, skip = 0, take = 1000 }: { where?: Record; skip?: number; take?: number }) => { const list = Object.values(rows) @@ -134,6 +161,56 @@ describe('ChatGateway.reconcileUpsert', () => { }); }); +function activity(over: Partial = {}): IChatActivity { + return { + sessionKey: 'bridle:admin', + channel: 'bridle', + externalUserId: 'admin', + eventId: 'ev-1', + role: 'user', + ts: 2000, + preview: 'hi', + ...over, + }; +} + +describe('ChatGateway.recordActivity', () => { + it('creates the row on the first activity with count 1', async () => { + const { gw } = newGateway(); + await gw.recordActivity('agent-1', activity({ role: 'user', preview: 'first' })); + const { items } = await gw.list({ agentId: 'agent-1' }); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + messageCount: 1, + userMessageCount: 1, + preview: 'first', + lastRole: 'user', + }); + }); + + it('increments monotonic counts and refreshes freshness on later activities', async () => { + const { gw } = newGateway(); + await gw.recordActivity('agent-1', activity({ eventId: 'e1', role: 'user' })); + await gw.recordActivity( + 'agent-1', + activity({ eventId: 'e2', role: 'assistant', preview: 'reply', ts: 3000 }), + ); + const s = (await gw.list({ agentId: 'agent-1' })).items[0]; + expect(s.messageCount).toBe(2); + expect(s.userMessageCount).toBe(1); // only the user event counted + expect(s.preview).toBe('reply'); + expect(s.lastRole).toBe('assistant'); + }); + + it('is idempotent for a repeated eventId (socket retry)', async () => { + const { gw } = newGateway(); + await gw.recordActivity('agent-1', activity({ eventId: 'dup' })); + await gw.recordActivity('agent-1', activity({ eventId: 'dup' })); + const s = (await gw.list({ agentId: 'agent-1' })).items[0]; + expect(s.messageCount).toBe(1); + }); +}); + describe('ChatGateway.list', () => { it('hides internal channel unless includeInternal, and returns total', async () => { const { gw } = newGateway(); diff --git a/api/src/slices/chat/data/chat.gateway.ts b/api/src/slices/chat/data/chat.gateway.ts index 889db5d..75b0128 100644 --- a/api/src/slices/chat/data/chat.gateway.ts +++ b/api/src/slices/chat/data/chat.gateway.ts @@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '#/setup/prisma/prisma.service'; import { IChatGateway, + IChatActivity, IChatFilter, IChatListResult, IChatReconcileInput, @@ -59,6 +60,44 @@ export class ChatGateway extends IChatGateway { return this.mapper.toEntity(record); } + async recordActivity(agentId: string, a: IChatActivity): Promise { + // Atomic increment in the DB — no read-modify-write, so concurrent events + // for the same session can't lose an increment. The NOT-filter dedups a + // redelivered event (eventId watermark). + if (await this.incrementActivity(agentId, a)) return; + + // No row matched: either a brand-new session, or a duplicate event on an + // existing one. Try to create; a unique violation means the row now exists + // (created by a concurrent event, or it's the dup) — re-run the increment, + // which applies for a real event and no-ops for a dup. + try { + await this.prisma.chatSession.create({ + data: this.mapper.toActivityCreate(agentId, a), + }); + } catch (err) { + if ((err as { code?: string }).code === 'P2002') { + await this.incrementActivity(agentId, a); + } else { + throw err; + } + } + } + + private async incrementActivity(agentId: string, a: IChatActivity): Promise { + const res = await this.prisma.chatSession.updateMany({ + where: { agentId, sessionKey: a.sessionKey, NOT: { lastIndexedEventId: a.eventId } }, + data: { + messageCount: { increment: 1 }, + ...(a.role === 'user' ? { userMessageCount: { increment: 1 } } : {}), + lastMessageAt: new Date(a.ts), + preview: a.preview, + lastRole: a.role, + lastIndexedEventId: a.eventId, + }, + }); + return res.count > 0; + } + private buildWhere(filter: IChatFilter): Prisma.ChatSessionWhereInput { const where: Prisma.ChatSessionWhereInput = {}; if (filter.agentId) where.agentId = filter.agentId; diff --git a/api/src/slices/chat/data/chat.mapper.ts b/api/src/slices/chat/data/chat.mapper.ts index 181be0c..3428e3a 100644 --- a/api/src/slices/chat/data/chat.mapper.ts +++ b/api/src/slices/chat/data/chat.mapper.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ChatSession } from '@prisma/client'; -import { IChatReconcileInput, IChatSessionData } from '../domain'; +import { IChatActivity, IChatReconcileInput, IChatSessionData } from '../domain'; @Injectable() export class ChatMapper { @@ -46,6 +46,25 @@ export class ChatMapper { }; } + // First-ever activity for a session → create the row with count 1. + toActivityCreate(agentId: string, a: IChatActivity) { + return { + id: `chat-${crypto.randomUUID()}`, + agentId, + channel: a.channel, + externalUserId: a.externalUserId, + sessionKey: a.sessionKey, + preview: a.preview, + lastRole: a.role, + lastMessageAt: new Date(a.ts), + messageCount: 1, + userMessageCount: a.role === 'user' ? 1 : 0, + lastIndexedEventId: a.eventId, + lastIndexedSize: 0, // realtime doesn't know the S3 size; reconcile sets it + archived: false, + }; + } + // Reconcile update: freshness always overwrites; counts are monotonic // (never lowered — the existing row's value is the floor). toReconcileUpdate(input: IChatReconcileInput, existing: ChatSession) { diff --git a/api/src/slices/chat/domain/chat.gateway.ts b/api/src/slices/chat/domain/chat.gateway.ts index c836b95..82e9327 100644 --- a/api/src/slices/chat/domain/chat.gateway.ts +++ b/api/src/slices/chat/domain/chat.gateway.ts @@ -1,4 +1,5 @@ import { + IChatActivity, IChatFilter, IChatListResult, IChatReconcileInput, @@ -20,4 +21,11 @@ export abstract class IChatGateway { * lowered (compaction shrinks the file, realtime owns the true total). */ abstract reconcileUpsert(input: IChatReconcileInput): Promise; + + /** + * Apply a live activity signal: create the row if new, else bump the + * monotonic counts (+1, dedup'd by eventId) and refresh + * lastMessageAt/preview/lastRole. Realtime is the authoritative count owner. + */ + abstract recordActivity(agentId: string, activity: IChatActivity): Promise; } diff --git a/api/src/slices/chat/domain/chat.types.ts b/api/src/slices/chat/domain/chat.types.ts index cb1a567..c68cea9 100644 --- a/api/src/slices/chat/domain/chat.types.ts +++ b/api/src/slices/chat/domain/chat.types.ts @@ -72,3 +72,18 @@ export interface IChatSyncResult { upserted: number; skipped: number; // unchanged since last reconcile } + +/** + * A live per-message signal emitted by the agent runtime over the hub socket + * (Phase 3). `agentId` is NOT here — it's taken from the authenticated socket, + * never trusted from the payload. + */ +export interface IChatActivity { + sessionKey: string; + channel: string; + externalUserId: string; + eventId: string; // Event.id — monotonic-count dedup watermark + role: 'user' | 'assistant'; + ts: number; // unix ms + preview: string; +} From 4216852ad3bc0932f525e8ce44626dc37d64e4bc Mon Sep 17 00:00:00 2001 From: maksym Date: Wed, 15 Jul 2026 19:33:56 +0200 Subject: [PATCH 04/21] =?UTF-8?q?fix(chat):=20eslint=20=E2=80=94=20drop=20?= =?UTF-8?q?redundant=20`unknown=20|=20null`,=20apply=20prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI (`bun run lint` = `eslint --fix`) failed on no-redundant-type-constituents: `insights: unknown | null` → `unknown` (unknown already subsumes null). This is the only non-auto-fixable error; the prettier violations CI was silently fixing each run are now applied to the committed chat slice + touched files, so the source is lint-clean. Co-Authored-By: Claude Opus 4.8 --- admin/slices/chat/stores/chat.ts | 2 +- .../domain/transcriptReader.service.spec.ts | 64 +++++++--- .../file/domain/transcriptReader.service.ts | 47 +++++-- api/src/slices/bridle/bridle.controller.ts | 7 +- api/src/slices/chat/chat.controller.ts | 37 ++++-- api/src/slices/chat/data/chat.gateway.spec.ts | 119 ++++++++++++++---- api/src/slices/chat/data/chat.gateway.ts | 22 +++- api/src/slices/chat/data/chat.mapper.ts | 11 +- api/src/slices/chat/domain/chat.gateway.ts | 9 +- api/src/slices/chat/domain/chat.types.ts | 5 +- .../chat/domain/chatSync.service.spec.ts | 52 ++++++-- .../slices/chat/domain/chatSync.service.ts | 39 ++++-- api/src/slices/chat/dtos/chatMessage.dto.ts | 17 ++- api/src/slices/chat/dtos/chatSession.dto.ts | 25 +++- api/src/slices/chat/dtos/filterChats.dto.ts | 26 +++- api/src/slices/chat/dtos/syncChats.dto.ts | 4 +- 16 files changed, 390 insertions(+), 96 deletions(-) diff --git a/admin/slices/chat/stores/chat.ts b/admin/slices/chat/stores/chat.ts index f45fb1b..47f4919 100644 --- a/admin/slices/chat/stores/chat.ts +++ b/admin/slices/chat/stores/chat.ts @@ -25,7 +25,7 @@ export interface IChatSession { userMessageCount: number; summary: string | null; summaryAt: string | null; - insights: unknown | null; + insights: unknown; // null when not yet computed archived: boolean; createdAt: string; updatedAt: string; diff --git a/api/src/slices/agent/file/domain/transcriptReader.service.spec.ts b/api/src/slices/agent/file/domain/transcriptReader.service.spec.ts index c35e5fd..464fa1e 100644 --- a/api/src/slices/agent/file/domain/transcriptReader.service.spec.ts +++ b/api/src/slices/agent/file/domain/transcriptReader.service.spec.ts @@ -1,4 +1,7 @@ -import { TranscriptReaderService, TranscriptMessage } from './transcriptReader.service'; +import { + TranscriptReaderService, + TranscriptMessage, +} from './transcriptReader.service'; import { IFileGateway } from './file.gateway'; import { IFileChunk } from './file.types'; @@ -6,10 +9,7 @@ import { IFileChunk } from './file.types'; function fileStub(content: string): IFileGateway { const size = Buffer.byteLength(content); return { - readRange: async ( - _agentId: string, - path: string, - ): Promise => ({ + readRange: async (_agentId: string, path: string): Promise => ({ path, content, size, @@ -26,7 +26,8 @@ function jsonl(...events: unknown[]): string { return events.map((e) => JSON.stringify(e)).join('\n') + '\n'; } -const reader = (content: string) => new TranscriptReaderService(fileStub(content)); +const reader = (content: string) => + new TranscriptReaderService(fileStub(content)); const roles = (m: TranscriptMessage[]) => m.map((x) => x.role); const texts = (m: TranscriptMessage[]) => m.map((x) => x.text); @@ -44,7 +45,12 @@ describe('TranscriptReaderService', () => { it('surfaces summary events when requested', async () => { const content = jsonl( - { id: 's', type: 'summary', ts: 1, data: { text: '[ARCHIVED CONTEXT] gist [END]' } }, + { + id: 's', + type: 'summary', + ts: 1, + data: { text: '[ARCHIVED CONTEXT] gist [END]' }, + }, { id: 'u', type: 'user', ts: 2, data: { text: 'and then?' } }, ); const withSummary = await reader(content).read('agent', 'p', { @@ -60,18 +66,41 @@ describe('TranscriptReaderService', () => { const content = jsonl( { id: 'u', type: 'user', ts: 1, data: { text: 'write a poem' } }, { id: 'p', type: 'assistant', ts: 2, data: { text: 'Roses are red,' } }, // partial - { id: 'c', type: 'user', ts: 3, data: { text: 'Your response was cut off. Continue…' } }, - { id: 'f', type: 'assistant', ts: 4, data: { text: 'Roses are red, violets are blue.' } }, + { + id: 'c', + type: 'user', + ts: 3, + data: { text: 'Your response was cut off. Continue…' }, + }, + { + id: 'f', + type: 'assistant', + ts: 4, + data: { text: 'Roses are red, violets are blue.' }, + }, ); const out = await reader(content).read('agent', 'p'); - expect(texts(out)).toEqual(['write a poem', 'Roses are red, violets are blue.']); + expect(texts(out)).toEqual([ + 'write a poem', + 'Roses are red, violets are blue.', + ]); }); it('drops synthetic events tagged data.transient (new data)', async () => { const content = jsonl( { id: 'u', type: 'user', ts: 1, data: { text: 'q' } }, - { id: 'p', type: 'assistant', ts: 2, data: { text: 'chunk', transient: true } }, - { id: 'c', type: 'user', ts: 3, data: { text: 'continue', transient: true } }, + { + id: 'p', + type: 'assistant', + ts: 2, + data: { text: 'chunk', transient: true }, + }, + { + id: 'c', + type: 'user', + ts: 3, + data: { text: 'continue', transient: true }, + }, { id: 'f', type: 'assistant', ts: 4, data: { text: 'full answer' } }, ); const out = await reader(content).read('agent', 'p'); @@ -82,10 +111,17 @@ describe('TranscriptReaderService', () => { const content = jsonl( { id: 'u', type: 'user', ts: 1, data: { text: 'q' } }, { id: 'p', type: 'assistant', ts: 2, data: { text: 'part' } }, - { id: 'c', type: 'user', ts: 3, data: { text: 'Your response was cut off. Continue…' } }, + { + id: 'c', + type: 'user', + ts: 3, + data: { text: 'Your response was cut off. Continue…' }, + }, { id: 'f', type: 'assistant', ts: 4, data: { text: 'part full' } }, ); - const out = await reader(content).read('agent', 'p', { filterTransient: false }); + const out = await reader(content).read('agent', 'p', { + filterTransient: false, + }); expect(out).toHaveLength(4); // nothing filtered }); diff --git a/api/src/slices/agent/file/domain/transcriptReader.service.ts b/api/src/slices/agent/file/domain/transcriptReader.service.ts index bd2d420..7ffbd84 100644 --- a/api/src/slices/agent/file/domain/transcriptReader.service.ts +++ b/api/src/slices/agent/file/domain/transcriptReader.service.ts @@ -4,7 +4,13 @@ import { IFileGateway } from './file.gateway'; /** One replayable line of a chat transcript. `role` is the source Event type. */ export interface TranscriptMessage { id: string; - role: 'user' | 'assistant' | 'summary' | 'tool_call' | 'tool_result' | 'system'; + role: + | 'user' + | 'assistant' + | 'summary' + | 'tool_call' + | 'tool_result' + | 'system'; text: string; ts: number; } @@ -35,7 +41,13 @@ interface RawEvent { id?: string; type?: string; ts?: number; - data?: { text?: string; transient?: boolean; name?: string; params?: unknown; result?: unknown }; + data?: { + text?: string; + transient?: boolean; + name?: string; + params?: unknown; + result?: unknown; + }; } /** @@ -74,7 +86,12 @@ export class TranscriptReaderService { if (!types.has(evt.type)) return; const text = this.render(evt); if (text === null) return; - messages.push({ id: evt.id, role: evt.type as TranscriptMessage['role'], text, ts: evt.ts }); + messages.push({ + id: evt.id, + role: evt.type as TranscriptMessage['role'], + text, + ts: evt.ts, + }); }); messages.sort((a, b) => a.ts - b.ts); return messages; @@ -89,7 +106,11 @@ export class TranscriptReaderService { all: TranscriptMessage[], cursor: string | undefined, limit: number, - ): { messages: TranscriptMessage[]; nextCursor: string | null; hasMore: boolean } { + ): { + messages: TranscriptMessage[]; + nextCursor: string | null; + hasMore: boolean; + } { const end = TranscriptReaderService.parseCursor(cursor, all.length); const start = Math.max(0, end - limit); const hasMore = start > 0; @@ -100,7 +121,10 @@ export class TranscriptReaderService { }; } - private static parseCursor(cursor: string | undefined, total: number): number { + private static parseCursor( + cursor: string | undefined, + total: number, + ): number { if (!cursor) return total; const parsed = parseInt(cursor, 10); if (!Number.isFinite(parsed) || parsed < 0) return total; @@ -111,7 +135,12 @@ export class TranscriptReaderService { let content = ''; let offset = 0; for (;;) { - const chunk = await this.files.readRange(agentId, path, offset, BLOCK_BYTES); + const chunk = await this.files.readRange( + agentId, + path, + offset, + BLOCK_BYTES, + ); content += chunk.content; if (content.length > MAX_TRANSCRIPT_BYTES) { throw new Error( @@ -150,13 +179,15 @@ export class TranscriptReaderService { !!e && e.type === 'user' && (e.data?.transient === true || - (typeof e.data?.text === 'string' && e.data.text.startsWith(CONTINUATION_PREFIX))); + (typeof e.data?.text === 'string' && + e.data.text.startsWith(CONTINUATION_PREFIX))); const drop = new Set(); events.forEach((e, i) => { if (e.data?.transient === true) drop.add(i); if (isContinuationUser(e)) drop.add(i); - if (e.type === 'assistant' && isContinuationUser(events[i + 1])) drop.add(i); + if (e.type === 'assistant' && isContinuationUser(events[i + 1])) + drop.add(i); }); return drop; } diff --git a/api/src/slices/bridle/bridle.controller.ts b/api/src/slices/bridle/bridle.controller.ts index 00a9525..6f259be 100644 --- a/api/src/slices/bridle/bridle.controller.ts +++ b/api/src/slices/bridle/bridle.controller.ts @@ -196,7 +196,12 @@ export class BridleController { query.cursor, limit, ); - return { messages: messages as TranscriptMessageDto[], channel, nextCursor, hasMore }; + return { + messages: messages as TranscriptMessageDto[], + channel, + nextCursor, + hasMore, + }; } @ApiOperation({ diff --git a/api/src/slices/chat/chat.controller.ts b/api/src/slices/chat/chat.controller.ts index f5e6f3b..83ca512 100644 --- a/api/src/slices/chat/chat.controller.ts +++ b/api/src/slices/chat/chat.controller.ts @@ -17,7 +17,10 @@ import { } from '@nestjs/swagger'; import { JwtAuthGuard, Roles, RolesGuard } from '#/user/auth/guards'; import { UserRoleTypes } from '#/user/user/domain'; -import { TranscriptReaderService, TranscriptMessage } from '#/agent/file/domain'; +import { + TranscriptReaderService, + TranscriptMessage, +} from '#/agent/file/domain'; import { IChatGateway, ChatSyncService } from './domain'; import { FilterChatsDto, @@ -37,7 +40,11 @@ const ALLOWED_TYPES: TranscriptMessage['role'][] = [ 'tool_result', 'system', ]; -const DEFAULT_TYPES: TranscriptMessage['role'][] = ['user', 'assistant', 'summary']; +const DEFAULT_TYPES: TranscriptMessage['role'][] = [ + 'user', + 'assistant', + 'summary', +]; @ApiTags('chats') @ApiBearerAuth() @@ -54,7 +61,8 @@ export class ChatController { ) {} @ApiOperation({ - description: 'List chat sessions (index). Filter by agent, channel, search; paginated.', + description: + 'List chat sessions (index). Filter by agent, channel, search; paginated.', operationId: 'getChats', }) @ApiOkResponse({ type: ChatListResponseDto }) @@ -62,11 +70,18 @@ export class ChatController { async list(@Query() filter: FilterChatsDto): Promise { const page = filter.page ?? 1; const perPage = filter.perPage ?? 50; - const { items, total } = await this.chats.list({ ...filter, page, perPage }); + const { items, total } = await this.chats.list({ + ...filter, + page, + perPage, + }); return { items, total, page, perPage }; } - @ApiOperation({ description: 'Get one chat session (index metadata).', operationId: 'getChat' }) + @ApiOperation({ + description: 'Get one chat session (index metadata).', + operationId: 'getChat', + }) @ApiOkResponse({ type: ChatSessionDto }) @Get(':id') async detail(@Param('id') id: string): Promise { @@ -95,13 +110,18 @@ export class ChatController { ? (q.types .split(',') .map((t) => t.trim()) - .filter((t) => (ALLOWED_TYPES as string[]).includes(t)) as TranscriptMessage['role'][]) + .filter((t) => + (ALLOWED_TYPES as string[]).includes(t), + ) as TranscriptMessage['role'][]) : DEFAULT_TYPES; const path = `data/sessions/${session.sessionKey}.jsonl`; let all: TranscriptMessage[]; try { - all = await this.reader.read(session.agentId, path, { types, filterTransient: true }); + all = await this.reader.read(session.agentId, path, { + types, + filterTransient: true, + }); } catch (err) { // Missing/unreadable file → empty transcript (row may predate the file, or // it was archived/reset). Don't 500 the whole page. @@ -115,7 +135,8 @@ export class ChatController { } @ApiOperation({ - description: 'Reconcile the chat index against S3 session files (all agents, or one).', + description: + 'Reconcile the chat index against S3 session files (all agents, or one).', operationId: 'syncChats', }) @ApiOkResponse({ type: SyncChatsResponseDto }) diff --git a/api/src/slices/chat/data/chat.gateway.spec.ts b/api/src/slices/chat/data/chat.gateway.spec.ts index b398634..b6203f3 100644 --- a/api/src/slices/chat/data/chat.gateway.spec.ts +++ b/api/src/slices/chat/data/chat.gateway.spec.ts @@ -6,14 +6,19 @@ import { IChatActivity, IChatReconcileInput } from '../domain'; function makePrismaStub() { const rows: Record> = {}; - const matches = (r: Record, where: Record = {}) => { + const matches = ( + r: Record, + where: Record = {}, + ) => { if (where.agentId && r.agentId !== where.agentId) return false; if (where.channel !== undefined) { const c = where.channel as string | { not?: string }; if (typeof c === 'string' && r.channel !== c) return false; - if (typeof c === 'object' && c.not !== undefined && r.channel === c.not) return false; + if (typeof c === 'object' && c.not !== undefined && r.channel === c.not) + return false; } - if (where.archived !== undefined && r.archived !== where.archived) return false; + if (where.archived !== undefined && r.archived !== where.archived) + return false; return true; }; @@ -37,7 +42,9 @@ function makePrismaStub() { (r) => r.agentId === data.agentId && r.sessionKey === data.sessionKey, ); if (clash) { - throw Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }); + throw Object.assign(new Error('Unique constraint failed'), { + code: 'P2002', + }); } rows[data.id as string] = { summary: null, @@ -51,17 +58,35 @@ function makePrismaStub() { }; return rows[data.id as string]; }), - update: jest.fn(async ({ where, data }: { where: { id: string }; data: Record }) => { - rows[where.id] = { ...rows[where.id], ...data }; - return rows[where.id]; - }), + update: jest.fn( + async ({ + where, + data, + }: { + where: { id: string }; + data: Record; + }) => { + rows[where.id] = { ...rows[where.id], ...data }; + return rows[where.id]; + }, + ), updateMany: jest.fn( - async ({ where, data }: { where: Record; data: Record }) => { + async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { let count = 0; for (const r of Object.values(rows)) { if (where.agentId && r.agentId !== where.agentId) continue; if (where.sessionKey && r.sessionKey !== where.sessionKey) continue; - if (where.NOT?.lastIndexedEventId !== undefined && r.lastIndexedEventId === where.NOT.lastIndexedEventId) continue; + if ( + where.NOT?.lastIndexedEventId !== undefined && + r.lastIndexedEventId === where.NOT.lastIndexedEventId + ) + continue; for (const [k, v] of Object.entries(data)) { if (v && typeof v === 'object' && 'increment' in v) { r[k] = ((r[k] as number) ?? 0) + (v.increment as number); @@ -75,15 +100,28 @@ function makePrismaStub() { }, ), findMany: jest.fn( - async ({ where, skip = 0, take = 1000 }: { where?: Record; skip?: number; take?: number }) => { + async ({ + where, + skip = 0, + take = 1000, + }: { + where?: Record; + skip?: number; + take?: number; + }) => { const list = Object.values(rows) .filter((r) => matches(r, where)) - .sort((a, b) => (b.lastMessageAt as Date).getTime() - (a.lastMessageAt as Date).getTime()); + .sort( + (a, b) => + (b.lastMessageAt as Date).getTime() - + (a.lastMessageAt as Date).getTime(), + ); return list.slice(skip, skip + take); }, ), - count: jest.fn(async ({ where }: { where?: Record }) => - Object.values(rows).filter((r) => matches(r, where)).length, + count: jest.fn( + async ({ where }: { where?: Record }) => + Object.values(rows).filter((r) => matches(r, where)).length, ), }; @@ -124,7 +162,9 @@ function newGateway() { describe('ChatGateway.reconcileUpsert', () => { it('creates a row on first index with the file counts as the floor', async () => { const { gw } = newGateway(); - const s = await gw.reconcileUpsert(input({ messageCount: 4, userMessageCount: 2 })); + const s = await gw.reconcileUpsert( + input({ messageCount: 4, userMessageCount: 2 }), + ); expect(s.messageCount).toBe(4); expect(s.userMessageCount).toBe(2); expect(s.lastIndexedSize).toBe(100); @@ -132,7 +172,9 @@ describe('ChatGateway.reconcileUpsert', () => { it('never lowers counts after compaction shrinks the file, but refreshes preview/lastMessageAt', async () => { const { gw } = newGateway(); - await gw.reconcileUpsert(input({ messageCount: 40, userMessageCount: 20, size: 5000 })); + await gw.reconcileUpsert( + input({ messageCount: 40, userMessageCount: 20, size: 5000 }), + ); // Compaction shrank the file → fewer viewable messages, newer last message. const after = await gw.reconcileUpsert( @@ -155,7 +197,9 @@ describe('ChatGateway.reconcileUpsert', () => { it('raises counts when the file grew', async () => { const { gw } = newGateway(); await gw.reconcileUpsert(input({ messageCount: 4, userMessageCount: 2 })); - const after = await gw.reconcileUpsert(input({ messageCount: 9, userMessageCount: 5 })); + const after = await gw.reconcileUpsert( + input({ messageCount: 9, userMessageCount: 5 }), + ); expect(after.messageCount).toBe(9); expect(after.userMessageCount).toBe(5); }); @@ -177,7 +221,10 @@ function activity(over: Partial = {}): IChatActivity { describe('ChatGateway.recordActivity', () => { it('creates the row on the first activity with count 1', async () => { const { gw } = newGateway(); - await gw.recordActivity('agent-1', activity({ role: 'user', preview: 'first' })); + await gw.recordActivity( + 'agent-1', + activity({ role: 'user', preview: 'first' }), + ); const { items } = await gw.list({ agentId: 'agent-1' }); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ @@ -190,10 +237,18 @@ describe('ChatGateway.recordActivity', () => { it('increments monotonic counts and refreshes freshness on later activities', async () => { const { gw } = newGateway(); - await gw.recordActivity('agent-1', activity({ eventId: 'e1', role: 'user' })); await gw.recordActivity( 'agent-1', - activity({ eventId: 'e2', role: 'assistant', preview: 'reply', ts: 3000 }), + activity({ eventId: 'e1', role: 'user' }), + ); + await gw.recordActivity( + 'agent-1', + activity({ + eventId: 'e2', + role: 'assistant', + preview: 'reply', + ts: 3000, + }), ); const s = (await gw.list({ agentId: 'agent-1' })).items[0]; expect(s.messageCount).toBe(2); @@ -214,9 +269,27 @@ describe('ChatGateway.recordActivity', () => { describe('ChatGateway.list', () => { it('hides internal channel unless includeInternal, and returns total', async () => { const { gw } = newGateway(); - await gw.reconcileUpsert(input({ sessionKey: 'bridle:a', channel: 'bridle', lastMessageAt: new Date(3000) })); - await gw.reconcileUpsert(input({ sessionKey: 'telegram:b', channel: 'telegram', lastMessageAt: new Date(2000) })); - await gw.reconcileUpsert(input({ sessionKey: 'internal:heartbeat', channel: 'internal', lastMessageAt: new Date(1000) })); + await gw.reconcileUpsert( + input({ + sessionKey: 'bridle:a', + channel: 'bridle', + lastMessageAt: new Date(3000), + }), + ); + await gw.reconcileUpsert( + input({ + sessionKey: 'telegram:b', + channel: 'telegram', + lastMessageAt: new Date(2000), + }), + ); + await gw.reconcileUpsert( + input({ + sessionKey: 'internal:heartbeat', + channel: 'internal', + lastMessageAt: new Date(1000), + }), + ); const visible = await gw.list({ agentId: 'agent-1' }); expect(visible.total).toBe(2); diff --git a/api/src/slices/chat/data/chat.gateway.ts b/api/src/slices/chat/data/chat.gateway.ts index 75b0128..71c605d 100644 --- a/api/src/slices/chat/data/chat.gateway.ts +++ b/api/src/slices/chat/data/chat.gateway.ts @@ -47,7 +47,12 @@ export class ChatGateway extends IChatGateway { async reconcileUpsert(input: IChatReconcileInput): Promise { const existing = await this.prisma.chatSession.findUnique({ - where: { agentId_sessionKey: { agentId: input.agentId, sessionKey: input.sessionKey } }, + where: { + agentId_sessionKey: { + agentId: input.agentId, + sessionKey: input.sessionKey, + }, + }, }); const record = existing @@ -55,7 +60,9 @@ export class ChatGateway extends IChatGateway { where: { id: existing.id }, data: this.mapper.toReconcileUpdate(input, existing), }) - : await this.prisma.chatSession.create({ data: this.mapper.toCreate(input) }); + : await this.prisma.chatSession.create({ + data: this.mapper.toCreate(input), + }); return this.mapper.toEntity(record); } @@ -83,9 +90,16 @@ export class ChatGateway extends IChatGateway { } } - private async incrementActivity(agentId: string, a: IChatActivity): Promise { + private async incrementActivity( + agentId: string, + a: IChatActivity, + ): Promise { const res = await this.prisma.chatSession.updateMany({ - where: { agentId, sessionKey: a.sessionKey, NOT: { lastIndexedEventId: a.eventId } }, + where: { + agentId, + sessionKey: a.sessionKey, + NOT: { lastIndexedEventId: a.eventId }, + }, data: { messageCount: { increment: 1 }, ...(a.role === 'user' ? { userMessageCount: { increment: 1 } } : {}), diff --git a/api/src/slices/chat/data/chat.mapper.ts b/api/src/slices/chat/data/chat.mapper.ts index 3428e3a..6cfcc21 100644 --- a/api/src/slices/chat/data/chat.mapper.ts +++ b/api/src/slices/chat/data/chat.mapper.ts @@ -1,6 +1,10 @@ import { Injectable } from '@nestjs/common'; import { ChatSession } from '@prisma/client'; -import { IChatActivity, IChatReconcileInput, IChatSessionData } from '../domain'; +import { + IChatActivity, + IChatReconcileInput, + IChatSessionData, +} from '../domain'; @Injectable() export class ChatMapper { @@ -78,7 +82,10 @@ export class ChatMapper { archived: input.archived, lastIndexedSize: input.size, messageCount: Math.max(existing.messageCount, input.messageCount), - userMessageCount: Math.max(existing.userMessageCount, input.userMessageCount), + userMessageCount: Math.max( + existing.userMessageCount, + input.userMessageCount, + ), }; } } diff --git a/api/src/slices/chat/domain/chat.gateway.ts b/api/src/slices/chat/domain/chat.gateway.ts index 82e9327..ab37d7c 100644 --- a/api/src/slices/chat/domain/chat.gateway.ts +++ b/api/src/slices/chat/domain/chat.gateway.ts @@ -20,12 +20,17 @@ export abstract class IChatGateway { * counts are monotonic — seeded on first index, only ever raised, never * lowered (compaction shrinks the file, realtime owns the true total). */ - abstract reconcileUpsert(input: IChatReconcileInput): Promise; + abstract reconcileUpsert( + input: IChatReconcileInput, + ): Promise; /** * Apply a live activity signal: create the row if new, else bump the * monotonic counts (+1, dedup'd by eventId) and refresh * lastMessageAt/preview/lastRole. Realtime is the authoritative count owner. */ - abstract recordActivity(agentId: string, activity: IChatActivity): Promise; + abstract recordActivity( + agentId: string, + activity: IChatActivity, + ): Promise; } diff --git a/api/src/slices/chat/domain/chat.types.ts b/api/src/slices/chat/domain/chat.types.ts index c68cea9..3140e09 100644 --- a/api/src/slices/chat/domain/chat.types.ts +++ b/api/src/slices/chat/domain/chat.types.ts @@ -6,7 +6,8 @@ export const ChatChannelTypes = { Slack: 'slack', Internal: 'internal', } as const; -export type ChatChannel = (typeof ChatChannelTypes)[keyof typeof ChatChannelTypes]; +export type ChatChannel = + (typeof ChatChannelTypes)[keyof typeof ChatChannelTypes]; /** A ChatSession index row (domain view of the Prisma model). */ export interface IChatSessionData { @@ -25,7 +26,7 @@ export interface IChatSessionData { lastIndexedSize: number; summary: string | null; summaryAt: Date | null; - insights: unknown | null; + insights: unknown; // Prisma Json? — null when not yet computed archived: boolean; createdAt: Date; updatedAt: Date; diff --git a/api/src/slices/chat/domain/chatSync.service.spec.ts b/api/src/slices/chat/domain/chatSync.service.spec.ts index 71ef6aa..f8ecd6d 100644 --- a/api/src/slices/chat/domain/chatSync.service.spec.ts +++ b/api/src/slices/chat/domain/chatSync.service.spec.ts @@ -2,7 +2,11 @@ import { ChatSyncService } from './chatSync.service'; import { IChatReconcileInput, IChatSessionData } from './chat.types'; import { IChatGateway } from './chat.gateway'; import { IAgentGateway } from '#/agent/agent/domain/agent.gateway'; -import { IFileGateway, IFileChunk, TranscriptReaderService } from '#/agent/file/domain'; +import { + IFileGateway, + IFileChunk, + TranscriptReaderService, +} from '#/agent/file/domain'; function jsonl(...events: unknown[]): string { return events.map((e) => JSON.stringify(e)).join('\n') + '\n'; @@ -13,11 +17,25 @@ function fileStub( contents: Record, ): IFileGateway { return { - list: async () => files.map((f) => ({ path: f.path, size: f.size, updatedAt: new Date(0) })), + list: async () => + files.map((f) => ({ + path: f.path, + size: f.size, + updatedAt: new Date(0), + })), readRange: async (_a: string, path: string): Promise => { const content = contents[path] ?? ''; const size = Buffer.byteLength(content); - return { path, content, size, totalSize: size, offset: 0, nextOffset: null, hasMore: false, updatedAt: new Date(0) }; + return { + path, + content, + size, + totalSize: size, + offset: 0, + nextOffset: null, + hasMore: false, + updatedAt: new Date(0), + }; }, } as unknown as IFileGateway; } @@ -25,7 +43,10 @@ function fileStub( function chatsStub(existing: Partial[]) { const upserts: IChatReconcileInput[] = []; const chats = { - list: async () => ({ items: existing as IChatSessionData[], total: existing.length }), + list: async () => ({ + items: existing as IChatSessionData[], + total: existing.length, + }), reconcileUpsert: async (i: IChatReconcileInput) => { upserts.push(i); return i as unknown as IChatSessionData; @@ -35,22 +56,37 @@ function chatsStub(existing: Partial[]) { return { chats, upserts }; } -const agentsStub = { findAll: async () => [{ id: 'agent-1' }] } as unknown as IAgentGateway; +const agentsStub = { + findAll: async () => [{ id: 'agent-1' }], +} as unknown as IAgentGateway; describe('ChatSyncService.syncAll', () => { it('indexes session files, skips unchanged (by size) and non-session files, and parses names', async () => { const bridleLive = jsonl( { id: 'u1', type: 'user', ts: 1, data: { text: 'hi' } }, { id: 'p', type: 'assistant', ts: 2, data: { text: 'part' } }, - { id: 'c', type: 'user', ts: 3, data: { text: 'Your response was cut off. Continue' } }, + { + id: 'c', + type: 'user', + ts: 3, + data: { text: 'Your response was cut off. Continue' }, + }, { id: 'a1', type: 'assistant', ts: 4, data: { text: 'part full' } }, ); - const archived = jsonl({ id: 'x', type: 'user', ts: 9, data: { text: 'old chat' } }); + const archived = jsonl({ + id: 'x', + type: 'user', + ts: 9, + data: { text: 'old chat' }, + }); const files = [ { path: 'data/sessions/bridle:admin.jsonl', size: 111 }, { path: 'data/sessions/telegram:555.jsonl', size: 50 }, // unchanged → skipped - { path: 'data/sessions/bridle:admin.2026-01-01T00-00-00.archived.jsonl', size: 22 }, + { + path: 'data/sessions/bridle:admin.2026-01-01T00-00-00.archived.jsonl', + size: 22, + }, { path: 'SOUL.md', size: 10 }, // not a session file ]; const contents = { diff --git a/api/src/slices/chat/domain/chatSync.service.ts b/api/src/slices/chat/domain/chatSync.service.ts index b066ea6..c0fc36f 100644 --- a/api/src/slices/chat/domain/chatSync.service.ts +++ b/api/src/slices/chat/domain/chatSync.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { + Injectable, + Logger, + OnModuleInit, + OnModuleDestroy, +} from '@nestjs/common'; import { IFileGateway, TranscriptReaderService } from '#/agent/file/domain'; import { IAgentGateway } from '#/agent/agent/domain/agent.gateway'; import { IChatGateway } from './chat.gateway'; @@ -34,7 +39,9 @@ export class ChatSyncService implements OnModuleInit, OnModuleDestroy { this.logger.log(`chat reconcile interval enabled: every ${sec}s`); this.timer = setInterval(() => { void this.syncAll().catch((err) => - this.logger.error(`scheduled reconcile failed: ${(err as Error).message}`), + this.logger.error( + `scheduled reconcile failed: ${(err as Error).message}`, + ), ); }, sec * 1000); } @@ -74,7 +81,10 @@ export class ChatSyncService implements OnModuleInit, OnModuleDestroy { return result; } - private async syncAgent(agentId: string, result: IChatSyncResult): Promise { + private async syncAgent( + agentId: string, + result: IChatSyncResult, + ): Promise { let nodes; try { nodes = await this.files.list(agentId); @@ -109,7 +119,12 @@ export class ChatSyncService implements OnModuleInit, OnModuleDestroy { } try { - const input = await this.buildReconcileInput(agentId, node.path, node.size, meta); + const input = await this.buildReconcileInput( + agentId, + node.path, + node.size, + meta, + ); await this.chats.reconcileUpsert(input); result.upserted++; } catch (err) { @@ -124,7 +139,12 @@ export class ChatSyncService implements OnModuleInit, OnModuleDestroy { agentId: string, path: string, size: number, - meta: { sessionKey: string; channel: string; externalUserId: string; archived: boolean }, + meta: { + sessionKey: string; + channel: string; + externalUserId: string; + archived: boolean; + }, ): Promise { const messages = await this.reader.read(agentId, path, { types: ['user', 'assistant'], @@ -151,9 +171,12 @@ export class ChatSyncService implements OnModuleInit, OnModuleDestroy { // `data/sessions/bridle:admin.jsonl` → { sessionKey:'bridle:admin', channel:'bridle', // externalUserId:'admin', archived:false }. Archived files are // `bridle:admin..archived.jsonl` → their own row, archived:true. - private parseName( - path: string, - ): { sessionKey: string; channel: string; externalUserId: string; archived: boolean } | null { + private parseName(path: string): { + sessionKey: string; + channel: string; + externalUserId: string; + archived: boolean; + } | null { const base = path.slice(SESSIONS_PREFIX.length, -'.jsonl'.length); const colon = base.indexOf(':'); if (colon < 0) return null; diff --git a/api/src/slices/chat/dtos/chatMessage.dto.ts b/api/src/slices/chat/dtos/chatMessage.dto.ts index 2d0074f..00d3087 100644 --- a/api/src/slices/chat/dtos/chatMessage.dto.ts +++ b/api/src/slices/chat/dtos/chatMessage.dto.ts @@ -6,20 +6,31 @@ export class ChatMessageDto { @ApiProperty({ example: 'c94dbcf2-…' }) id: string; @ApiProperty({ - enum: ['user', 'assistant', 'summary', 'tool_call', 'tool_result', 'system'], + enum: [ + 'user', + 'assistant', + 'summary', + 'tool_call', + 'tool_result', + 'system', + ], example: 'assistant', }) role: string; @ApiProperty({ example: 'Hello, how can I help?' }) text: string; - @ApiProperty({ example: 1777562539964, description: 'Unix epoch ms' }) ts: number; + @ApiProperty({ example: 1777562539964, description: 'Unix epoch ms' }) + ts: number; } export class ChatMessagesResponseDto { @ApiProperty({ type: [ChatMessageDto] }) messages: ChatMessageDto[]; - @ApiPropertyOptional({ nullable: true, description: 'Pass to fetch the previous (older) page' }) + @ApiPropertyOptional({ + nullable: true, + description: 'Pass to fetch the previous (older) page', + }) nextCursor: string | null; @ApiProperty() hasMore: boolean; diff --git a/api/src/slices/chat/dtos/chatSession.dto.ts b/api/src/slices/chat/dtos/chatSession.dto.ts index c8b7fb9..ddeb3b7 100644 --- a/api/src/slices/chat/dtos/chatSession.dto.ts +++ b/api/src/slices/chat/dtos/chatSession.dto.ts @@ -6,7 +6,10 @@ export class ChatSessionDto { @ApiProperty() agentId: string; - @ApiProperty({ enum: ['bridle', 'telegram', 'slack', 'internal'], example: 'bridle' }) + @ApiProperty({ + enum: ['bridle', 'telegram', 'slack', 'internal'], + example: 'bridle', + }) channel: string; @ApiProperty({ example: 'admin' }) externalUserId: string; @@ -15,16 +18,23 @@ export class ChatSessionDto { @ApiPropertyOptional({ nullable: true }) title: string | null; - @ApiPropertyOptional({ nullable: true, description: 'Last message text, truncated' }) + @ApiPropertyOptional({ + nullable: true, + description: 'Last message text, truncated', + }) preview: string | null; @ApiPropertyOptional({ enum: ['user', 'assistant'], nullable: true }) lastRole: string | null; - @ApiProperty({ description: 'Unix ms via ISO', example: '2026-07-15T09:12:00.000Z' }) + @ApiProperty({ + description: 'Unix ms via ISO', + example: '2026-07-15T09:12:00.000Z', + }) lastMessageAt: Date; - @ApiProperty({ description: 'Monotonic lifetime total' }) messageCount: number; + @ApiProperty({ description: 'Monotonic lifetime total' }) + messageCount: number; @ApiProperty() userMessageCount: number; @@ -32,8 +42,11 @@ export class ChatSessionDto { @ApiPropertyOptional({ nullable: true }) summaryAt: Date | null; - @ApiPropertyOptional({ nullable: true, description: 'topics/sentiment/resolved/language' }) - insights: unknown | null; + @ApiPropertyOptional({ + nullable: true, + description: 'topics/sentiment/resolved/language', + }) + insights: unknown; @ApiProperty() archived: boolean; diff --git a/api/src/slices/chat/dtos/filterChats.dto.ts b/api/src/slices/chat/dtos/filterChats.dto.ts index c5a0037..4ab13be 100644 --- a/api/src/slices/chat/dtos/filterChats.dto.ts +++ b/api/src/slices/chat/dtos/filterChats.dto.ts @@ -1,8 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; -const toBool = ({ value }: { value: unknown }) => value === true || value === 'true'; +const toBool = ({ value }: { value: unknown }) => + value === true || value === 'true'; export class FilterChatsDto { @ApiPropertyOptional({ description: 'Restrict to one agent' }) @@ -15,18 +23,26 @@ export class FilterChatsDto { @IsString() channel?: string; - @ApiPropertyOptional({ description: 'Matches title / preview / externalUserId' }) + @ApiPropertyOptional({ + description: 'Matches title / preview / externalUserId', + }) @IsOptional() @IsString() search?: string; - @ApiPropertyOptional({ default: false, description: 'Show archived sessions' }) + @ApiPropertyOptional({ + default: false, + description: 'Show archived sessions', + }) @IsOptional() @Transform(toBool) @IsBoolean() archived?: boolean; - @ApiPropertyOptional({ default: false, description: 'Include internal (cron/heartbeat) sessions' }) + @ApiPropertyOptional({ + default: false, + description: 'Include internal (cron/heartbeat) sessions', + }) @IsOptional() @Transform(toBool) @IsBoolean() diff --git a/api/src/slices/chat/dtos/syncChats.dto.ts b/api/src/slices/chat/dtos/syncChats.dto.ts index 697ec7f..a68011d 100644 --- a/api/src/slices/chat/dtos/syncChats.dto.ts +++ b/api/src/slices/chat/dtos/syncChats.dto.ts @@ -2,7 +2,9 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; export class SyncChatsDto { - @ApiPropertyOptional({ description: 'Reconcile only this agent; omit for all agents' }) + @ApiPropertyOptional({ + description: 'Reconcile only this agent; omit for all agents', + }) @IsOptional() @IsString() agentId?: string; From 326ac2925f11c6f12e492c00cfb11916e088bdfb Mon Sep 17 00:00:00 2001 From: maksym Date: Thu, 16 Jul 2026 11:07:53 +0200 Subject: [PATCH 05/21] =?UTF-8?q?feat(chat):=20LLM=20insights=20+=20summar?= =?UTF-8?q?ies=20=E2=80=94=20cron-batch=20(Phase=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate a per-chat summary + structured insights (topics, sentiment, resolved, language) via the llm slice's active Anthropic credential. - ChatInsightService: on-demand summarize + a gated cron-batch (CHAT_INSIGHT_INTERVAL_SEC; setInterval like ChatSyncService). Eligibility gate never touches empty/unchanged chats: userMessageCount >= 3, settled (lastMessageAt older than 1h), new activity since last summary (summaryAt IS NULL OR lastMessageAt > summaryAt via a Prisma field ref), non-internal, per-run cap. Reads the transcript via TranscriptReaderService (tail-truncated), tolerant JSON parse, skips when no Anthropic credential. - IChatGateway.findEligibleForInsight + saveInsights (stamps summaryAt = now). - POST /chats/:id/summarize (Owner/Admin). - Admin: chat detail shows the summary + insight badges (topics / sentiment / resolved / language) and a Summarize / Re-summarize button. Regenerated the admin + app OpenAPI clients (summarizeChat). - Specs: parseInsights, summarize, runBatch (mocked LLM). tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 --- .../chat/components/chatDetail/Provider.vue | 55 ++- admin/slices/chat/stores/chat.ts | 16 +- .../api/data/repositories/api/schemas.gen.ts | 394 +++++++++--------- .../api/data/repositories/api/sdk.gen.ts | 174 ++++---- .../api/data/repositories/api/types.gen.ts | 370 ++++++++-------- api/src/slices/chat/chat.controller.ts | 14 +- api/src/slices/chat/chat.module.ts | 10 +- api/src/slices/chat/data/chat.gateway.ts | 39 ++ api/src/slices/chat/domain/chat.gateway.ts | 19 + api/src/slices/chat/domain/chat.types.ts | 23 + .../chat/domain/chatInsight.service.spec.ts | 169 ++++++++ .../slices/chat/domain/chatInsight.service.ts | 257 ++++++++++++ api/src/slices/chat/domain/index.ts | 1 + .../api/data/repositories/api/schemas.gen.ts | 394 +++++++++--------- .../api/data/repositories/api/sdk.gen.ts | 174 ++++---- .../api/data/repositories/api/types.gen.ts | 370 ++++++++-------- 16 files changed, 1564 insertions(+), 915 deletions(-) create mode 100644 api/src/slices/chat/domain/chatInsight.service.spec.ts create mode 100644 api/src/slices/chat/domain/chatInsight.service.ts diff --git a/admin/slices/chat/components/chatDetail/Provider.vue b/admin/slices/chat/components/chatDetail/Provider.vue index ee27190..e2f1221 100644 --- a/admin/slices/chat/components/chatDetail/Provider.vue +++ b/admin/slices/chat/components/chatDetail/Provider.vue @@ -12,6 +12,24 @@ const { data: session } = await useAsyncData(`chat-detail-${props.id}`, () => store.getById(props.id), ); +const summarizing = ref(false); +async function onSummarize() { + summarizing.value = true; + try { + const updated = await store.summarize(props.id); + if (updated) session.value = updated; + } finally { + summarizing.value = false; + } +} + +const sentimentVariant: Record = { + positive: 'default', + neutral: 'secondary', + negative: 'outline', + mixed: 'secondary', +}; + const PAGE = 50; const messages = ref([]); const cursor = ref(null); @@ -95,12 +113,37 @@ function fmt(iso?: string | null): string { Last activity {{ fmt(session.lastMessageAt) }} {{ session.sessionKey }} -

- {{ session.summary }} -

+ +
+
+ Summary & insights + +
+

+ {{ session.summary }} +

+

+ No summary yet — click Summarize to generate one. +

+
+ + {{ topic }} + + + {{ session.insights.sentiment }} + + + {{ session.insights.resolved ? 'resolved' : 'unresolved' }} + + {{ session.insights.language }} +
+
diff --git a/admin/slices/chat/stores/chat.ts b/admin/slices/chat/stores/chat.ts index 47f4919..30f63c9 100644 --- a/admin/slices/chat/stores/chat.ts +++ b/admin/slices/chat/stores/chat.ts @@ -11,6 +11,13 @@ function unwrap(body: unknown): T | null { export type ChatChannel = 'bridle' | 'telegram' | 'slack' | 'internal'; +export interface IChatInsights { + topics: string[]; + sentiment: 'positive' | 'neutral' | 'negative' | 'mixed'; + resolved: boolean; + language: string; +} + export interface IChatSession { id: string; agentId: string; @@ -25,7 +32,7 @@ export interface IChatSession { userMessageCount: number; summary: string | null; summaryAt: string | null; - insights: unknown; // null when not yet computed + insights: IChatInsights | null; // null when not yet computed archived: boolean; createdAt: string; updatedAt: string; @@ -121,5 +128,10 @@ export const useChatStore = defineStore('chat', () => { return unwrap(res.data); } - return { list, getById, messages, sync }; + async function summarize(id: string): Promise { + const res = await ChatsService.summarizeChat({ path: { id } }); + return unwrap(res.data); + } + + return { list, getById, messages, sync, summarize }; }); diff --git a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts index f7a6c1d..1a78f38 100644 --- a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts @@ -1252,6 +1252,203 @@ export const UpdateSkillDtoSchema = { }, } as const; +export const ChatSessionDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + example: "chat-9f2c…", + }, + agentId: { + type: "string", + }, + channel: { + type: "string", + enum: ["bridle", "telegram", "slack", "internal"], + example: "bridle", + }, + externalUserId: { + type: "string", + example: "admin", + }, + sessionKey: { + type: "string", + example: "bridle:admin", + }, + title: { + type: "object", + nullable: true, + }, + preview: { + type: "object", + nullable: true, + description: "Last message text, truncated", + }, + lastRole: { + type: "string", + enum: ["user", "assistant"], + nullable: true, + }, + lastMessageAt: { + format: "date-time", + type: "string", + description: "Unix ms via ISO", + example: "2026-07-15T09:12:00.000Z", + }, + messageCount: { + type: "number", + description: "Monotonic lifetime total", + }, + userMessageCount: { + type: "number", + }, + summary: { + type: "object", + nullable: true, + }, + summaryAt: { + type: "object", + nullable: true, + }, + insights: { + type: "object", + nullable: true, + description: "topics/sentiment/resolved/language", + }, + archived: { + type: "boolean", + }, + createdAt: { + format: "date-time", + type: "string", + }, + updatedAt: { + format: "date-time", + type: "string", + }, + }, + required: [ + "id", + "agentId", + "channel", + "externalUserId", + "sessionKey", + "lastMessageAt", + "messageCount", + "userMessageCount", + "archived", + "createdAt", + "updatedAt", + ], +} as const; + +export const ChatListResponseDtoSchema = { + type: "object", + properties: { + items: { + type: "array", + items: { + $ref: "#/components/schemas/ChatSessionDto", + }, + }, + total: { + type: "number", + example: 128, + }, + page: { + type: "number", + example: 1, + }, + perPage: { + type: "number", + example: 50, + }, + }, + required: ["items", "total", "page", "perPage"], +} as const; + +export const ChatMessageDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + example: "c94dbcf2-…", + }, + role: { + type: "string", + enum: [ + "user", + "assistant", + "summary", + "tool_call", + "tool_result", + "system", + ], + example: "assistant", + }, + text: { + type: "string", + example: "Hello, how can I help?", + }, + ts: { + type: "number", + example: 1777562539964, + description: "Unix epoch ms", + }, + }, + required: ["id", "role", "text", "ts"], +} as const; + +export const ChatMessagesResponseDtoSchema = { + type: "object", + properties: { + messages: { + type: "array", + items: { + $ref: "#/components/schemas/ChatMessageDto", + }, + }, + nextCursor: { + type: "object", + nullable: true, + description: "Pass to fetch the previous (older) page", + }, + hasMore: { + type: "boolean", + }, + }, + required: ["messages", "hasMore"], +} as const; + +export const SyncChatsDtoSchema = { + type: "object", + properties: { + agentId: { + type: "string", + description: "Reconcile only this agent; omit for all agents", + }, + }, +} as const; + +export const SyncChatsResponseDtoSchema = { + type: "object", + properties: { + scannedAgents: { + type: "number", + }, + scannedFiles: { + type: "number", + }, + upserted: { + type: "number", + }, + skipped: { + type: "number", + }, + }, + required: ["scannedAgents", "scannedFiles", "upserted", "skipped"], +} as const; + export const TelegramChannelConfigDtoSchema = { type: "object", properties: { @@ -1919,203 +2116,6 @@ export const ReportUsageDtoSchema = { required: ["date", "byModel"], } as const; -export const ChatSessionDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - example: "chat-9f2c…", - }, - agentId: { - type: "string", - }, - channel: { - type: "string", - enum: ["bridle", "telegram", "slack", "internal"], - example: "bridle", - }, - externalUserId: { - type: "string", - example: "admin", - }, - sessionKey: { - type: "string", - example: "bridle:admin", - }, - title: { - type: "object", - nullable: true, - }, - preview: { - type: "object", - nullable: true, - description: "Last message text, truncated", - }, - lastRole: { - type: "string", - enum: ["user", "assistant"], - nullable: true, - }, - lastMessageAt: { - format: "date-time", - type: "string", - description: "Unix ms via ISO", - example: "2026-07-15T09:12:00.000Z", - }, - messageCount: { - type: "number", - description: "Monotonic lifetime total", - }, - userMessageCount: { - type: "number", - }, - summary: { - type: "object", - nullable: true, - }, - summaryAt: { - type: "object", - nullable: true, - }, - insights: { - type: "object", - nullable: true, - description: "topics/sentiment/resolved/language", - }, - archived: { - type: "boolean", - }, - createdAt: { - format: "date-time", - type: "string", - }, - updatedAt: { - format: "date-time", - type: "string", - }, - }, - required: [ - "id", - "agentId", - "channel", - "externalUserId", - "sessionKey", - "lastMessageAt", - "messageCount", - "userMessageCount", - "archived", - "createdAt", - "updatedAt", - ], -} as const; - -export const ChatListResponseDtoSchema = { - type: "object", - properties: { - items: { - type: "array", - items: { - $ref: "#/components/schemas/ChatSessionDto", - }, - }, - total: { - type: "number", - example: 128, - }, - page: { - type: "number", - example: 1, - }, - perPage: { - type: "number", - example: 50, - }, - }, - required: ["items", "total", "page", "perPage"], -} as const; - -export const ChatMessageDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - example: "c94dbcf2-…", - }, - role: { - type: "string", - enum: [ - "user", - "assistant", - "summary", - "tool_call", - "tool_result", - "system", - ], - example: "assistant", - }, - text: { - type: "string", - example: "Hello, how can I help?", - }, - ts: { - type: "number", - example: 1777562539964, - description: "Unix epoch ms", - }, - }, - required: ["id", "role", "text", "ts"], -} as const; - -export const ChatMessagesResponseDtoSchema = { - type: "object", - properties: { - messages: { - type: "array", - items: { - $ref: "#/components/schemas/ChatMessageDto", - }, - }, - nextCursor: { - type: "object", - nullable: true, - description: "Pass to fetch the previous (older) page", - }, - hasMore: { - type: "boolean", - }, - }, - required: ["messages", "hasMore"], -} as const; - -export const SyncChatsDtoSchema = { - type: "object", - properties: { - agentId: { - type: "string", - description: "Reconcile only this agent; omit for all agents", - }, - }, -} as const; - -export const SyncChatsResponseDtoSchema = { - type: "object", - properties: { - scannedAgents: { - type: "number", - }, - scannedFiles: { - type: "number", - }, - upserted: { - type: "number", - }, - skipped: { - type: "number", - }, - }, - required: ["scannedAgents", "scannedFiles", "upserted", "skipped"], -} as const; - export const RunPaddockJudgeOverrideDtoSchema = { type: "object", properties: { diff --git a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts index b278e52..0995c26 100644 --- a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -115,6 +115,16 @@ import type { SkillControllerFindByIdData, SkillControllerUpdateData, FindDependentAgentsData, + GetChatsData, + GetChatsResponse, + GetChatData, + GetChatResponse, + GetChatMessagesData, + GetChatMessagesResponse, + SyncChatsData, + SyncChatsResponse, + SummarizeChatData, + SummarizeChatResponse, GetAgentChannelsData, GetAgentChannelsResponse, SetAgentChannelsData, @@ -157,14 +167,6 @@ import type { UsageControllerReportData, UsageControllerReportResponse, UsageControllerFindForCredentialData, - GetChatsData, - GetChatsResponse, - GetChatData, - GetChatResponse, - GetChatMessagesData, - GetChatMessagesResponse, - SyncChatsData, - SyncChatsResponse, RancherControllerStatusData, RancherControllerEnsureTemplateData, UpgradeControllerStatusData, @@ -1968,6 +1970,92 @@ export class SkillsService { } } +export class ChatsService { + /** + * List chat sessions (index). Filter by agent, channel, search; paginated. + */ + public static getChats( + options?: Options, + ) { + return (options?.client ?? _heyApiClient).get< + GetChatsResponse, + unknown, + ThrowOnError + >({ + url: "/chats", + ...options, + }); + } + + /** + * Get one chat session (index metadata). + */ + public static getChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetChatResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}", + ...options, + }); + } + + /** + * Replay a chat session's transcript from S3, tail-first. `summary` markers are shown inline (compaction folds old turns into them); synthetic loop-control events are filtered. Admins may add tool_call,tool_result via `types`. + */ + public static getChatMessages( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetChatMessagesResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/messages", + ...options, + }); + } + + /** + * Reconcile the chat index against S3 session files (all agents, or one). + */ + public static syncChats( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + SyncChatsResponse, + unknown, + ThrowOnError + >({ + url: "/chats/sync", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } + + /** + * Generate (or refresh) an LLM summary + insights for one chat, on demand. + */ + public static summarizeChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + SummarizeChatResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/summarize", + ...options, + }); + } +} + export class UsersService { /** * List all users @@ -2413,76 +2501,6 @@ export class UsageService { } } -export class ChatsService { - /** - * List chat sessions (index). Filter by agent, channel, search; paginated. - */ - public static getChats( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - GetChatsResponse, - unknown, - ThrowOnError - >({ - url: "/chats", - ...options, - }); - } - - /** - * Get one chat session (index metadata). - */ - public static getChat( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetChatResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}", - ...options, - }); - } - - /** - * Replay a chat session's transcript from S3, tail-first. `summary` markers are shown inline (compaction folds old turns into them); synthetic loop-control events are filtered. Admins may add tool_call,tool_result via `types`. - */ - public static getChatMessages( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetChatMessagesResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}/messages", - ...options, - }); - } - - /** - * Reconcile the chat index against S3 session files (all agents, or one). - */ - public static syncChats( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - SyncChatsResponse, - unknown, - ThrowOnError - >({ - url: "/chats/sync", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } -} - export class RancherService { /** * Stepper state for the Rancher setup wizard: do we have an LLM, the special template, and an admin agent? diff --git a/admin/slices/setup/api/data/repositories/api/types.gen.ts b/admin/slices/setup/api/data/repositories/api/types.gen.ts index be7e46d..6ade06a 100644 --- a/admin/slices/setup/api/data/repositories/api/types.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/types.gen.ts @@ -572,6 +572,96 @@ export type UpdateSkillDto = { description?: string; }; +export type ChatSessionDto = { + id: string; + agentId: string; + channel: "bridle" | "telegram" | "slack" | "internal"; + externalUserId: string; + sessionKey: string; + title?: { + [key: string]: unknown; + } | null; + /** + * Last message text, truncated + */ + preview?: { + [key: string]: unknown; + } | null; + lastRole?: "user" | "assistant"; + /** + * Unix ms via ISO + */ + lastMessageAt: string; + /** + * Monotonic lifetime total + */ + messageCount: number; + userMessageCount: number; + summary?: { + [key: string]: unknown; + } | null; + summaryAt?: { + [key: string]: unknown; + } | null; + /** + * topics/sentiment/resolved/language + */ + insights?: { + [key: string]: unknown; + } | null; + archived: boolean; + createdAt: string; + updatedAt: string; +}; + +export type ChatListResponseDto = { + items: Array; + total: number; + page: number; + perPage: number; +}; + +export type ChatMessageDto = { + id: string; + role: + | "user" + | "assistant" + | "summary" + | "tool_call" + | "tool_result" + | "system"; + text: string; + /** + * Unix epoch ms + */ + ts: number; +}; + +export type ChatMessagesResponseDto = { + messages: Array; + /** + * Pass to fetch the previous (older) page + */ + nextCursor?: { + [key: string]: unknown; + } | null; + hasMore: boolean; +}; + +export type SyncChatsDto = { + /** + * Reconcile only this agent; omit for all agents + */ + agentId?: string; +}; + +export type SyncChatsResponseDto = { + scannedAgents: number; + scannedFiles: number; + upserted: number; + skipped: number; +}; + export type TelegramChannelConfigDto = { /** * Telegram bot HTTP API token (issued by @BotFather). @@ -838,96 +928,6 @@ export type ReportUsageDto = { }; }; -export type ChatSessionDto = { - id: string; - agentId: string; - channel: "bridle" | "telegram" | "slack" | "internal"; - externalUserId: string; - sessionKey: string; - title?: { - [key: string]: unknown; - } | null; - /** - * Last message text, truncated - */ - preview?: { - [key: string]: unknown; - } | null; - lastRole?: "user" | "assistant"; - /** - * Unix ms via ISO - */ - lastMessageAt: string; - /** - * Monotonic lifetime total - */ - messageCount: number; - userMessageCount: number; - summary?: { - [key: string]: unknown; - } | null; - summaryAt?: { - [key: string]: unknown; - } | null; - /** - * topics/sentiment/resolved/language - */ - insights?: { - [key: string]: unknown; - } | null; - archived: boolean; - createdAt: string; - updatedAt: string; -}; - -export type ChatListResponseDto = { - items: Array; - total: number; - page: number; - perPage: number; -}; - -export type ChatMessageDto = { - id: string; - role: - | "user" - | "assistant" - | "summary" - | "tool_call" - | "tool_result" - | "system"; - text: string; - /** - * Unix epoch ms - */ - ts: number; -}; - -export type ChatMessagesResponseDto = { - messages: Array; - /** - * Pass to fetch the previous (older) page - */ - nextCursor?: { - [key: string]: unknown; - } | null; - hasMore: boolean; -}; - -export type SyncChatsDto = { - /** - * Reconcile only this agent; omit for all agents - */ - agentId?: string; -}; - -export type SyncChatsResponseDto = { - scannedAgents: number; - scannedFiles: number; - upserted: number; - skipped: number; -}; - export type RunPaddockJudgeOverrideDto = { credentialIds?: Array; threshold?: number; @@ -2483,6 +2483,109 @@ export type FindDependentAgentsResponses = { 200: unknown; }; +export type GetChatsData = { + body?: never; + path?: never; + query?: { + /** + * Restrict to one agent + */ + agentId?: string; + channel?: "bridle" | "telegram" | "slack" | "internal"; + /** + * Matches title / preview / externalUserId + */ + search?: string; + /** + * Show archived sessions + */ + archived?: boolean; + /** + * Include internal (cron/heartbeat) sessions + */ + includeInternal?: boolean; + page?: number; + perPage?: number; + }; + url: "/chats"; +}; + +export type GetChatsResponses = { + 200: ChatListResponseDto; +}; + +export type GetChatsResponse = GetChatsResponses[keyof GetChatsResponses]; + +export type GetChatData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}"; +}; + +export type GetChatResponses = { + 200: ChatSessionDto; +}; + +export type GetChatResponse = GetChatResponses[keyof GetChatResponses]; + +export type GetChatMessagesData = { + body?: never; + path: { + id: string; + }; + query?: { + limit?: number; + /** + * Opaque cursor from a previous page + */ + cursor?: string; + /** + * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. + */ + types?: string; + }; + url: "/chats/{id}/messages"; +}; + +export type GetChatMessagesResponses = { + 200: ChatMessagesResponseDto; +}; + +export type GetChatMessagesResponse = + GetChatMessagesResponses[keyof GetChatMessagesResponses]; + +export type SyncChatsData = { + body: SyncChatsDto; + path?: never; + query?: never; + url: "/chats/sync"; +}; + +export type SyncChatsResponses = { + 200: SyncChatsResponseDto; +}; + +export type SyncChatsResponse = SyncChatsResponses[keyof SyncChatsResponses]; + +export type SummarizeChatData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}/summarize"; +}; + +export type SummarizeChatResponses = { + 200: ChatSessionDto; +}; + +export type SummarizeChatResponse = + SummarizeChatResponses[keyof SummarizeChatResponses]; + export type GetAgentChannelsData = { body?: never; path: { @@ -2935,93 +3038,6 @@ export type UsageControllerFindForCredentialResponses = { 200: unknown; }; -export type GetChatsData = { - body?: never; - path?: never; - query?: { - /** - * Restrict to one agent - */ - agentId?: string; - channel?: "bridle" | "telegram" | "slack" | "internal"; - /** - * Matches title / preview / externalUserId - */ - search?: string; - /** - * Show archived sessions - */ - archived?: boolean; - /** - * Include internal (cron/heartbeat) sessions - */ - includeInternal?: boolean; - page?: number; - perPage?: number; - }; - url: "/chats"; -}; - -export type GetChatsResponses = { - 200: ChatListResponseDto; -}; - -export type GetChatsResponse = GetChatsResponses[keyof GetChatsResponses]; - -export type GetChatData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/chats/{id}"; -}; - -export type GetChatResponses = { - 200: ChatSessionDto; -}; - -export type GetChatResponse = GetChatResponses[keyof GetChatResponses]; - -export type GetChatMessagesData = { - body?: never; - path: { - id: string; - }; - query?: { - limit?: number; - /** - * Opaque cursor from a previous page - */ - cursor?: string; - /** - * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. - */ - types?: string; - }; - url: "/chats/{id}/messages"; -}; - -export type GetChatMessagesResponses = { - 200: ChatMessagesResponseDto; -}; - -export type GetChatMessagesResponse = - GetChatMessagesResponses[keyof GetChatMessagesResponses]; - -export type SyncChatsData = { - body: SyncChatsDto; - path?: never; - query?: never; - url: "/chats/sync"; -}; - -export type SyncChatsResponses = { - 200: SyncChatsResponseDto; -}; - -export type SyncChatsResponse = SyncChatsResponses[keyof SyncChatsResponses]; - export type RancherControllerStatusData = { body?: never; path?: never; diff --git a/api/src/slices/chat/chat.controller.ts b/api/src/slices/chat/chat.controller.ts index 83ca512..f6f9ba9 100644 --- a/api/src/slices/chat/chat.controller.ts +++ b/api/src/slices/chat/chat.controller.ts @@ -21,7 +21,7 @@ import { TranscriptReaderService, TranscriptMessage, } from '#/agent/file/domain'; -import { IChatGateway, ChatSyncService } from './domain'; +import { IChatGateway, ChatSyncService, ChatInsightService } from './domain'; import { FilterChatsDto, ChatListResponseDto, @@ -58,6 +58,7 @@ export class ChatController { private readonly chats: IChatGateway, private readonly reader: TranscriptReaderService, private readonly sync: ChatSyncService, + private readonly insight: ChatInsightService, ) {} @ApiOperation({ @@ -144,4 +145,15 @@ export class ChatController { async syncChats(@Body() dto: SyncChatsDto): Promise { return this.sync.syncAll(dto.agentId); } + + @ApiOperation({ + description: + 'Generate (or refresh) an LLM summary + insights for one chat, on demand.', + operationId: 'summarizeChat', + }) + @ApiOkResponse({ type: ChatSessionDto }) + @Post(':id/summarize') + async summarize(@Param('id') id: string): Promise { + return this.insight.summarize(id); + } } diff --git a/api/src/slices/chat/chat.module.ts b/api/src/slices/chat/chat.module.ts index c436f79..736a16d 100644 --- a/api/src/slices/chat/chat.module.ts +++ b/api/src/slices/chat/chat.module.ts @@ -1,19 +1,25 @@ import { Module, forwardRef } from '@nestjs/common'; import { FileModule } from '#/agent/file/file.module'; import { AgentModule } from '#/agent/agent/agent.module'; +import { LlmModule } from '#/llm/llm.module'; import { ChatController } from './chat.controller'; -import { IChatGateway, ChatSyncService } from './domain'; +import { IChatGateway, ChatSyncService, ChatInsightService } from './domain'; import { ChatGateway } from './data/chat.gateway'; import { ChatMapper } from './data/chat.mapper'; @Module({ // forwardRef because BridleModule now imports ChatModule, forming the cycle // Bridle → Chat → File → Bridle (File already forwardRefs Bridle). - imports: [forwardRef(() => FileModule), forwardRef(() => AgentModule)], + imports: [ + forwardRef(() => FileModule), + forwardRef(() => AgentModule), + LlmModule, + ], controllers: [ChatController], providers: [ ChatMapper, ChatSyncService, + ChatInsightService, { provide: IChatGateway, useClass: ChatGateway, diff --git a/api/src/slices/chat/data/chat.gateway.ts b/api/src/slices/chat/data/chat.gateway.ts index 71c605d..94dbee7 100644 --- a/api/src/slices/chat/data/chat.gateway.ts +++ b/api/src/slices/chat/data/chat.gateway.ts @@ -5,6 +5,8 @@ import { IChatGateway, IChatActivity, IChatFilter, + IChatInsightGate, + IChatInsights, IChatListResult, IChatReconcileInput, IChatSessionData, @@ -112,6 +114,43 @@ export class ChatGateway extends IChatGateway { return res.count > 0; } + async findEligibleForInsight( + gate: IChatInsightGate, + ): Promise { + const settledBefore = new Date(Date.now() - gate.cooldownMs); + const records = await this.prisma.chatSession.findMany({ + where: { + channel: { not: 'internal' }, + archived: false, + userMessageCount: { gte: gate.minUserMessages }, + lastMessageAt: { lt: settledBefore }, + // New activity since the last summary (null = never summarized). + OR: [ + { summaryAt: null }, + { lastMessageAt: { gt: this.prisma.chatSession.fields.summaryAt } }, + ], + }, + orderBy: { lastMessageAt: 'desc' }, + take: gate.limit, + }); + return records.map((r) => this.mapper.toEntity(r)); + } + + async saveInsights( + id: string, + summary: string, + insights: IChatInsights, + ): Promise { + await this.prisma.chatSession.update({ + where: { id }, + data: { + summary, + insights: insights as unknown as Prisma.InputJsonValue, + summaryAt: new Date(), + }, + }); + } + private buildWhere(filter: IChatFilter): Prisma.ChatSessionWhereInput { const where: Prisma.ChatSessionWhereInput = {}; if (filter.agentId) where.agentId = filter.agentId; diff --git a/api/src/slices/chat/domain/chat.gateway.ts b/api/src/slices/chat/domain/chat.gateway.ts index ab37d7c..7825bfd 100644 --- a/api/src/slices/chat/domain/chat.gateway.ts +++ b/api/src/slices/chat/domain/chat.gateway.ts @@ -1,6 +1,8 @@ import { IChatActivity, IChatFilter, + IChatInsightGate, + IChatInsights, IChatListResult, IChatReconcileInput, IChatSessionData, @@ -33,4 +35,21 @@ export abstract class IChatGateway { agentId: string, activity: IChatActivity, ): Promise; + + /** + * Sessions eligible for insight generation: not internal, not archived, + * enough user messages, settled (no recent activity), and with new activity + * since the last summary (`summaryAt IS NULL OR lastMessageAt > summaryAt`). + * Ordered most-recently-active first, capped at `gate.limit`. + */ + abstract findEligibleForInsight( + gate: IChatInsightGate, + ): Promise; + + /** Persist an LLM summary + structured insights; stamps `summaryAt = now`. */ + abstract saveInsights( + id: string, + summary: string, + insights: IChatInsights, + ): Promise; } diff --git a/api/src/slices/chat/domain/chat.types.ts b/api/src/slices/chat/domain/chat.types.ts index 3140e09..b95c555 100644 --- a/api/src/slices/chat/domain/chat.types.ts +++ b/api/src/slices/chat/domain/chat.types.ts @@ -88,3 +88,26 @@ export interface IChatActivity { ts: number; // unix ms preview: string; } + +export type ChatSentiment = 'positive' | 'neutral' | 'negative' | 'mixed'; + +/** LLM-derived structured insights, stored in `ChatSession.insights` (Json). */ +export interface IChatInsights { + topics: string[]; + sentiment: ChatSentiment; + resolved: boolean; + language: string; // ISO 639-1, e.g. "en", "ru" +} + +/** Eligibility gate for the insight cron-batch (never summarize empty/unchanged). */ +export interface IChatInsightGate { + cooldownMs: number; // lastMessageAt must be older than now - cooldown (settled) + minUserMessages: number; // skip trivial chats + limit: number; // per-run batch cap (bounds token spend) +} + +export interface IChatInsightBatchResult { + eligible: number; + summarized: number; + failed: number; +} diff --git a/api/src/slices/chat/domain/chatInsight.service.spec.ts b/api/src/slices/chat/domain/chatInsight.service.spec.ts new file mode 100644 index 0000000..685bc2b --- /dev/null +++ b/api/src/slices/chat/domain/chatInsight.service.spec.ts @@ -0,0 +1,169 @@ +import { ChatInsightService, parseInsights } from './chatInsight.service'; +import { IChatGateway } from './chat.gateway'; +import { ILlmGateway } from '#/llm/domain'; +import { TranscriptReaderService } from '#/agent/file/domain'; +import { IChatSessionData } from './chat.types'; + +const SESSION: IChatSessionData = { + id: 'c1', + agentId: 'a1', + channel: 'bridle', + externalUserId: 'admin', + sessionKey: 'bridle:admin', + title: null, + preview: 'hi', + lastRole: 'assistant', + lastMessageAt: new Date(2000), + messageCount: 6, + userMessageCount: 3, + lastIndexedEventId: null, + lastIndexedSize: 100, + summary: null, + summaryAt: null, + insights: null, + archived: false, + createdAt: new Date(0), + updatedAt: new Date(0), +}; + +const VALID = + '{"summary":"User asked about billing. Assistant explained plans.","topics":["billing","plans"],"sentiment":"positive","resolved":true,"language":"en"}'; + +function readerStub() { + return { + read: jest.fn(async () => [ + { id: 'm1', role: 'user', text: 'hi', ts: 1 }, + { id: 'm2', role: 'assistant', text: 'hello', ts: 2 }, + ]), + } as unknown as TranscriptReaderService; +} + +function llmsStub( + active: Array<{ provider: string; model: string; apiKey: string }> = [ + { provider: 'anthropic', model: 'claude-x', apiKey: 'sk' }, + ], +) { + return { findActive: jest.fn(async () => active) } as unknown as ILlmGateway; +} + +// Returns the mock fns alongside the gateway so assertions target the jest.fn +// locals (not interface-typed method refs, which trip unbound-method). +function makeChats( + opts: { findById?: jest.Mock; eligible?: IChatSessionData[] } = {}, +) { + const findById = opts.findById ?? jest.fn(async () => SESSION); + const saveInsights = jest.fn(async () => {}); + const findEligibleForInsight = jest.fn(async () => opts.eligible ?? []); + const chats = { + findById, + saveInsights, + findEligibleForInsight, + } as unknown as IChatGateway; + return { chats, findById, saveInsights, findEligibleForInsight }; +} + +function mockFetch(text: string, ok = true) { + global.fetch = jest.fn(async () => ({ + ok, + status: ok ? 200 : 500, + json: async () => ({ content: [{ type: 'text', text }] }), + text: async () => text, + })) as unknown as typeof fetch; +} + +describe('parseInsights', () => { + it('parses a clean JSON object', () => { + const p = parseInsights(VALID)!; + expect(p.summary).toContain('billing'); + expect(p.topics).toEqual(['billing', 'plans']); + expect(p.sentiment).toBe('positive'); + expect(p.resolved).toBe(true); + expect(p.language).toBe('en'); + }); + + it('strips markdown fences and surrounding prose', () => { + expect(parseInsights('```json\n' + VALID + '\n```')?.summary).toContain( + 'billing', + ); + }); + + it('returns null without a summary', () => { + expect(parseInsights('{"topics":[]}')).toBeNull(); + expect(parseInsights('not json at all')).toBeNull(); + }); + + it('defaults an invalid sentiment to neutral and non-boolean resolved to false', () => { + const p = parseInsights( + '{"summary":"x","sentiment":"angry","resolved":"yes"}', + )!; + expect(p.sentiment).toBe('neutral'); + expect(p.resolved).toBe(false); + expect(p.topics).toEqual([]); + expect(p.language).toBe('en'); + }); +}); + +describe('ChatInsightService.summarize', () => { + const orig = global.fetch; + afterEach(() => { + global.fetch = orig; + }); + + it('reads transcript → LLM → saveInsights', async () => { + mockFetch(VALID); + const { chats, saveInsights } = makeChats(); + const svc = new ChatInsightService(chats, readerStub(), llmsStub()); + await svc.summarize('c1'); + expect(saveInsights).toHaveBeenCalledWith( + 'c1', + 'User asked about billing. Assistant explained plans.', + { + topics: ['billing', 'plans'], + sentiment: 'positive', + resolved: true, + language: 'en', + }, + ); + }); + + it('throws when no Anthropic credential exists', async () => { + const { chats } = makeChats(); + const svc = new ChatInsightService(chats, readerStub(), llmsStub([])); + await expect(svc.summarize('c1')).rejects.toThrow(/credential/i); + }); + + it('throws NotFound for a missing chat', async () => { + const { chats } = makeChats({ findById: jest.fn(async () => null) }); + const svc = new ChatInsightService(chats, readerStub(), llmsStub()); + await expect(svc.summarize('nope')).rejects.toThrow(/not found/i); + }); +}); + +describe('ChatInsightService.runBatch', () => { + const orig = global.fetch; + afterEach(() => { + global.fetch = orig; + }); + + it('summarizes every eligible session', async () => { + mockFetch(VALID); + const { chats, saveInsights } = makeChats({ + eligible: [SESSION, { ...SESSION, id: 'c2' }], + }); + const svc = new ChatInsightService(chats, readerStub(), llmsStub()); + const r = await svc.runBatch(); + expect(r.eligible).toBe(2); + expect(r.summarized).toBe(2); + expect(saveInsights.mock.calls.length).toBe(2); + }); + + it('skips entirely (no gate query) when no credential', async () => { + const { chats, findEligibleForInsight } = makeChats({ + eligible: [SESSION], + }); + const svc = new ChatInsightService(chats, readerStub(), llmsStub([])); + const r = await svc.runBatch(); + expect(r.summarized).toBe(0); + expect(findEligibleForInsight).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/slices/chat/domain/chatInsight.service.ts b/api/src/slices/chat/domain/chatInsight.service.ts new file mode 100644 index 0000000..df76cd2 --- /dev/null +++ b/api/src/slices/chat/domain/chatInsight.service.ts @@ -0,0 +1,257 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ILlmGateway } from '#/llm/domain'; +import type { ILlmCredentialData } from '#/llm/domain'; +import { TranscriptReaderService } from '#/agent/file/domain'; +import { IChatGateway } from './chat.gateway'; +import { + ChatSentiment, + IChatInsightBatchResult, + IChatInsightGate, + IChatInsights, + IChatSessionData, +} from './chat.types'; + +const ANTHROPIC_DEFAULT_MODEL = 'claude-sonnet-4-6'; +const TRANSCRIPT_CAP = 12_000; // chars — tail-truncate long chats (v1) +const SENTIMENTS: ChatSentiment[] = [ + 'positive', + 'neutral', + 'negative', + 'mixed', +]; + +/** + * Generates an LLM summary + structured insights per chat and stores them on + * the ChatSession. Runs on demand (`POST /chats/:id/summarize`) and, if + * `CHAT_INSIGHT_INTERVAL_SEC` is set, as a gated cron-batch (never summarizes + * empty or unchanged chats — see IChatInsightGate). Same setInterval pattern as + * ChatSyncService (ranch has no scheduler facility). + */ +@Injectable() +export class ChatInsightService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ChatInsightService.name); + private timer?: ReturnType; + private running = false; + + private readonly gate: IChatInsightGate = { + cooldownMs: 60 * 60 * 1000, // 1h settled + minUserMessages: 3, + limit: 25, // per-run cap bounds token spend + }; + + constructor( + private readonly chats: IChatGateway, + private readonly reader: TranscriptReaderService, + private readonly llms: ILlmGateway, + ) {} + + onModuleInit(): void { + const sec = Number(process.env.CHAT_INSIGHT_INTERVAL_SEC ?? 0); + if (!Number.isFinite(sec) || sec <= 0) return; + this.logger.log(`chat insight batch enabled: every ${sec}s`); + this.timer = setInterval(() => { + void this.runBatch().catch((err) => + this.logger.error(`insight batch failed: ${(err as Error).message}`), + ); + }, sec * 1000); + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer); + } + + /** On-demand summarize (admin button). Returns the updated session. */ + async summarize(id: string): Promise { + const session = await this.chats.findById(id); + if (!session) throw new NotFoundException(`Chat ${id} not found`); + const credential = await this.pickCredential(); + if (!credential) { + throw new BadRequestException( + 'No active Anthropic LLM credential — add one in Settings → LLM credentials.', + ); + } + await this.summarizeSession(session, credential); + return (await this.chats.findById(id)) ?? session; + } + + /** Gated cron-batch. Skips entirely when no Anthropic credential exists. */ + async runBatch(): Promise { + const empty: IChatInsightBatchResult = { + eligible: 0, + summarized: 0, + failed: 0, + }; + if (this.running) { + this.logger.warn('insight batch already running — skipping'); + return empty; + } + const credential = await this.pickCredential(); + if (!credential) { + this.logger.debug('insight batch: no Anthropic credential — skipping'); + return empty; + } + + this.running = true; + const result = { ...empty }; + try { + const sessions = await this.chats.findEligibleForInsight(this.gate); + result.eligible = sessions.length; + for (const session of sessions) { + try { + await this.summarizeSession(session, credential); + result.summarized++; + } catch (err) { + result.failed++; + this.logger.warn( + `insight failed for ${session.id}: ${(err as Error).message}`, + ); + } + } + } finally { + this.running = false; + } + this.logger.log( + `insight batch: eligible=${result.eligible} summarized=${result.summarized} failed=${result.failed}`, + ); + return result; + } + + private async summarizeSession( + session: IChatSessionData, + credential: ILlmCredentialData, + ): Promise { + const transcript = await this.loadTranscript(session); + if (!transcript.trim()) return; // nothing to summarize (e.g. file gone) + + const raw = await this.callClaude( + credential.apiKey, + credential.model || ANTHROPIC_DEFAULT_MODEL, + buildInsightPrompt(transcript), + ); + const parsed = parseInsights(raw); + if (!parsed) { + throw new Error( + `insight generator returned no valid JSON: ${raw.slice(0, 200)}`, + ); + } + const { summary, ...insights } = parsed; + await this.chats.saveInsights(session.id, summary, insights); + } + + private async loadTranscript(session: IChatSessionData): Promise { + const path = `data/sessions/${session.sessionKey}.jsonl`; + let messages; + try { + messages = await this.reader.read(session.agentId, path, { + types: ['user', 'assistant'], + filterTransient: true, + }); + } catch { + return ''; + } + let text = messages.map((m) => `${m.role}: ${m.text}`).join('\n'); + if (text.length > TRANSCRIPT_CAP) { + text = `…(earlier messages truncated)…\n${text.slice(-TRANSCRIPT_CAP)}`; + } + return text; + } + + private async pickCredential(): Promise { + const active = await this.llms.findActive(); + return ( + active.find((c) => { + const p = c.provider.toLowerCase(); + return p === 'claude' || p === 'anthropic'; + }) ?? null + ); + } + + private async callClaude( + apiKey: string, + model: string, + prompt: string, + ): Promise { + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }], + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Anthropic API ${res.status}: ${body.slice(0, 200)}`); + } + const json = (await res.json()) as { + content?: Array<{ type: string; text?: string }>; + }; + return (json.content ?? []) + .filter((b) => b.type === 'text') + .map((b) => b.text ?? '') + .join('\n') + .trim(); + } +} + +export function buildInsightPrompt(transcript: string): string { + return `You are analyzing a conversation between a user (the human) and an AI assistant (the agent). Produce a concise summary and structured insights. + +Conversation: +""" +${transcript} +""" + +Output ONLY a JSON object — no markdown fences, no commentary — shaped exactly like: +{ + "summary": "2-4 sentence third-person recap: what the user wanted and what happened", + "topics": ["short", "topic", "tags"], + "sentiment": "positive" | "neutral" | "negative" | "mixed", + "resolved": true, + "language": "ISO 639-1 code of the user's language, e.g. en, ru" +}`; +} + +export interface ParsedInsight extends IChatInsights { + summary: string; +} + +/** Tolerant parse of the LLM's JSON output. Returns null if unusable. */ +export function parseInsights(raw: string): ParsedInsight | null { + const match = raw + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```$/i, '') + .match(/\{[\s\S]*\}/); + if (!match) return null; + let obj: Record; + try { + obj = JSON.parse(match[0]) as Record; + } catch { + return null; + } + if (typeof obj.summary !== 'string' || !obj.summary.trim()) return null; + const sentiment = obj.sentiment as ChatSentiment; + return { + summary: obj.summary, + topics: Array.isArray(obj.topics) + ? obj.topics + .filter((t): t is string => typeof t === 'string') + .slice(0, 12) + : [], + sentiment: SENTIMENTS.includes(sentiment) ? sentiment : 'neutral', + resolved: typeof obj.resolved === 'boolean' ? obj.resolved : false, + language: typeof obj.language === 'string' ? obj.language : 'en', + }; +} diff --git a/api/src/slices/chat/domain/index.ts b/api/src/slices/chat/domain/index.ts index 7f7e081..cef4ab9 100644 --- a/api/src/slices/chat/domain/index.ts +++ b/api/src/slices/chat/domain/index.ts @@ -1,3 +1,4 @@ export * from './chat.types'; export * from './chat.gateway'; export * from './chatSync.service'; +export * from './chatInsight.service'; diff --git a/app/slices/setup/api/data/repositories/api/schemas.gen.ts b/app/slices/setup/api/data/repositories/api/schemas.gen.ts index f7a6c1d..1a78f38 100644 --- a/app/slices/setup/api/data/repositories/api/schemas.gen.ts +++ b/app/slices/setup/api/data/repositories/api/schemas.gen.ts @@ -1252,6 +1252,203 @@ export const UpdateSkillDtoSchema = { }, } as const; +export const ChatSessionDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + example: "chat-9f2c…", + }, + agentId: { + type: "string", + }, + channel: { + type: "string", + enum: ["bridle", "telegram", "slack", "internal"], + example: "bridle", + }, + externalUserId: { + type: "string", + example: "admin", + }, + sessionKey: { + type: "string", + example: "bridle:admin", + }, + title: { + type: "object", + nullable: true, + }, + preview: { + type: "object", + nullable: true, + description: "Last message text, truncated", + }, + lastRole: { + type: "string", + enum: ["user", "assistant"], + nullable: true, + }, + lastMessageAt: { + format: "date-time", + type: "string", + description: "Unix ms via ISO", + example: "2026-07-15T09:12:00.000Z", + }, + messageCount: { + type: "number", + description: "Monotonic lifetime total", + }, + userMessageCount: { + type: "number", + }, + summary: { + type: "object", + nullable: true, + }, + summaryAt: { + type: "object", + nullable: true, + }, + insights: { + type: "object", + nullable: true, + description: "topics/sentiment/resolved/language", + }, + archived: { + type: "boolean", + }, + createdAt: { + format: "date-time", + type: "string", + }, + updatedAt: { + format: "date-time", + type: "string", + }, + }, + required: [ + "id", + "agentId", + "channel", + "externalUserId", + "sessionKey", + "lastMessageAt", + "messageCount", + "userMessageCount", + "archived", + "createdAt", + "updatedAt", + ], +} as const; + +export const ChatListResponseDtoSchema = { + type: "object", + properties: { + items: { + type: "array", + items: { + $ref: "#/components/schemas/ChatSessionDto", + }, + }, + total: { + type: "number", + example: 128, + }, + page: { + type: "number", + example: 1, + }, + perPage: { + type: "number", + example: 50, + }, + }, + required: ["items", "total", "page", "perPage"], +} as const; + +export const ChatMessageDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + example: "c94dbcf2-…", + }, + role: { + type: "string", + enum: [ + "user", + "assistant", + "summary", + "tool_call", + "tool_result", + "system", + ], + example: "assistant", + }, + text: { + type: "string", + example: "Hello, how can I help?", + }, + ts: { + type: "number", + example: 1777562539964, + description: "Unix epoch ms", + }, + }, + required: ["id", "role", "text", "ts"], +} as const; + +export const ChatMessagesResponseDtoSchema = { + type: "object", + properties: { + messages: { + type: "array", + items: { + $ref: "#/components/schemas/ChatMessageDto", + }, + }, + nextCursor: { + type: "object", + nullable: true, + description: "Pass to fetch the previous (older) page", + }, + hasMore: { + type: "boolean", + }, + }, + required: ["messages", "hasMore"], +} as const; + +export const SyncChatsDtoSchema = { + type: "object", + properties: { + agentId: { + type: "string", + description: "Reconcile only this agent; omit for all agents", + }, + }, +} as const; + +export const SyncChatsResponseDtoSchema = { + type: "object", + properties: { + scannedAgents: { + type: "number", + }, + scannedFiles: { + type: "number", + }, + upserted: { + type: "number", + }, + skipped: { + type: "number", + }, + }, + required: ["scannedAgents", "scannedFiles", "upserted", "skipped"], +} as const; + export const TelegramChannelConfigDtoSchema = { type: "object", properties: { @@ -1919,203 +2116,6 @@ export const ReportUsageDtoSchema = { required: ["date", "byModel"], } as const; -export const ChatSessionDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - example: "chat-9f2c…", - }, - agentId: { - type: "string", - }, - channel: { - type: "string", - enum: ["bridle", "telegram", "slack", "internal"], - example: "bridle", - }, - externalUserId: { - type: "string", - example: "admin", - }, - sessionKey: { - type: "string", - example: "bridle:admin", - }, - title: { - type: "object", - nullable: true, - }, - preview: { - type: "object", - nullable: true, - description: "Last message text, truncated", - }, - lastRole: { - type: "string", - enum: ["user", "assistant"], - nullable: true, - }, - lastMessageAt: { - format: "date-time", - type: "string", - description: "Unix ms via ISO", - example: "2026-07-15T09:12:00.000Z", - }, - messageCount: { - type: "number", - description: "Monotonic lifetime total", - }, - userMessageCount: { - type: "number", - }, - summary: { - type: "object", - nullable: true, - }, - summaryAt: { - type: "object", - nullable: true, - }, - insights: { - type: "object", - nullable: true, - description: "topics/sentiment/resolved/language", - }, - archived: { - type: "boolean", - }, - createdAt: { - format: "date-time", - type: "string", - }, - updatedAt: { - format: "date-time", - type: "string", - }, - }, - required: [ - "id", - "agentId", - "channel", - "externalUserId", - "sessionKey", - "lastMessageAt", - "messageCount", - "userMessageCount", - "archived", - "createdAt", - "updatedAt", - ], -} as const; - -export const ChatListResponseDtoSchema = { - type: "object", - properties: { - items: { - type: "array", - items: { - $ref: "#/components/schemas/ChatSessionDto", - }, - }, - total: { - type: "number", - example: 128, - }, - page: { - type: "number", - example: 1, - }, - perPage: { - type: "number", - example: 50, - }, - }, - required: ["items", "total", "page", "perPage"], -} as const; - -export const ChatMessageDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - example: "c94dbcf2-…", - }, - role: { - type: "string", - enum: [ - "user", - "assistant", - "summary", - "tool_call", - "tool_result", - "system", - ], - example: "assistant", - }, - text: { - type: "string", - example: "Hello, how can I help?", - }, - ts: { - type: "number", - example: 1777562539964, - description: "Unix epoch ms", - }, - }, - required: ["id", "role", "text", "ts"], -} as const; - -export const ChatMessagesResponseDtoSchema = { - type: "object", - properties: { - messages: { - type: "array", - items: { - $ref: "#/components/schemas/ChatMessageDto", - }, - }, - nextCursor: { - type: "object", - nullable: true, - description: "Pass to fetch the previous (older) page", - }, - hasMore: { - type: "boolean", - }, - }, - required: ["messages", "hasMore"], -} as const; - -export const SyncChatsDtoSchema = { - type: "object", - properties: { - agentId: { - type: "string", - description: "Reconcile only this agent; omit for all agents", - }, - }, -} as const; - -export const SyncChatsResponseDtoSchema = { - type: "object", - properties: { - scannedAgents: { - type: "number", - }, - scannedFiles: { - type: "number", - }, - upserted: { - type: "number", - }, - skipped: { - type: "number", - }, - }, - required: ["scannedAgents", "scannedFiles", "upserted", "skipped"], -} as const; - export const RunPaddockJudgeOverrideDtoSchema = { type: "object", properties: { diff --git a/app/slices/setup/api/data/repositories/api/sdk.gen.ts b/app/slices/setup/api/data/repositories/api/sdk.gen.ts index b278e52..0995c26 100644 --- a/app/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/app/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -115,6 +115,16 @@ import type { SkillControllerFindByIdData, SkillControllerUpdateData, FindDependentAgentsData, + GetChatsData, + GetChatsResponse, + GetChatData, + GetChatResponse, + GetChatMessagesData, + GetChatMessagesResponse, + SyncChatsData, + SyncChatsResponse, + SummarizeChatData, + SummarizeChatResponse, GetAgentChannelsData, GetAgentChannelsResponse, SetAgentChannelsData, @@ -157,14 +167,6 @@ import type { UsageControllerReportData, UsageControllerReportResponse, UsageControllerFindForCredentialData, - GetChatsData, - GetChatsResponse, - GetChatData, - GetChatResponse, - GetChatMessagesData, - GetChatMessagesResponse, - SyncChatsData, - SyncChatsResponse, RancherControllerStatusData, RancherControllerEnsureTemplateData, UpgradeControllerStatusData, @@ -1968,6 +1970,92 @@ export class SkillsService { } } +export class ChatsService { + /** + * List chat sessions (index). Filter by agent, channel, search; paginated. + */ + public static getChats( + options?: Options, + ) { + return (options?.client ?? _heyApiClient).get< + GetChatsResponse, + unknown, + ThrowOnError + >({ + url: "/chats", + ...options, + }); + } + + /** + * Get one chat session (index metadata). + */ + public static getChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetChatResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}", + ...options, + }); + } + + /** + * Replay a chat session's transcript from S3, tail-first. `summary` markers are shown inline (compaction folds old turns into them); synthetic loop-control events are filtered. Admins may add tool_call,tool_result via `types`. + */ + public static getChatMessages( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetChatMessagesResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/messages", + ...options, + }); + } + + /** + * Reconcile the chat index against S3 session files (all agents, or one). + */ + public static syncChats( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + SyncChatsResponse, + unknown, + ThrowOnError + >({ + url: "/chats/sync", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } + + /** + * Generate (or refresh) an LLM summary + insights for one chat, on demand. + */ + public static summarizeChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + SummarizeChatResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/summarize", + ...options, + }); + } +} + export class UsersService { /** * List all users @@ -2413,76 +2501,6 @@ export class UsageService { } } -export class ChatsService { - /** - * List chat sessions (index). Filter by agent, channel, search; paginated. - */ - public static getChats( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - GetChatsResponse, - unknown, - ThrowOnError - >({ - url: "/chats", - ...options, - }); - } - - /** - * Get one chat session (index metadata). - */ - public static getChat( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetChatResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}", - ...options, - }); - } - - /** - * Replay a chat session's transcript from S3, tail-first. `summary` markers are shown inline (compaction folds old turns into them); synthetic loop-control events are filtered. Admins may add tool_call,tool_result via `types`. - */ - public static getChatMessages( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetChatMessagesResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}/messages", - ...options, - }); - } - - /** - * Reconcile the chat index against S3 session files (all agents, or one). - */ - public static syncChats( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - SyncChatsResponse, - unknown, - ThrowOnError - >({ - url: "/chats/sync", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } -} - export class RancherService { /** * Stepper state for the Rancher setup wizard: do we have an LLM, the special template, and an admin agent? diff --git a/app/slices/setup/api/data/repositories/api/types.gen.ts b/app/slices/setup/api/data/repositories/api/types.gen.ts index be7e46d..6ade06a 100644 --- a/app/slices/setup/api/data/repositories/api/types.gen.ts +++ b/app/slices/setup/api/data/repositories/api/types.gen.ts @@ -572,6 +572,96 @@ export type UpdateSkillDto = { description?: string; }; +export type ChatSessionDto = { + id: string; + agentId: string; + channel: "bridle" | "telegram" | "slack" | "internal"; + externalUserId: string; + sessionKey: string; + title?: { + [key: string]: unknown; + } | null; + /** + * Last message text, truncated + */ + preview?: { + [key: string]: unknown; + } | null; + lastRole?: "user" | "assistant"; + /** + * Unix ms via ISO + */ + lastMessageAt: string; + /** + * Monotonic lifetime total + */ + messageCount: number; + userMessageCount: number; + summary?: { + [key: string]: unknown; + } | null; + summaryAt?: { + [key: string]: unknown; + } | null; + /** + * topics/sentiment/resolved/language + */ + insights?: { + [key: string]: unknown; + } | null; + archived: boolean; + createdAt: string; + updatedAt: string; +}; + +export type ChatListResponseDto = { + items: Array; + total: number; + page: number; + perPage: number; +}; + +export type ChatMessageDto = { + id: string; + role: + | "user" + | "assistant" + | "summary" + | "tool_call" + | "tool_result" + | "system"; + text: string; + /** + * Unix epoch ms + */ + ts: number; +}; + +export type ChatMessagesResponseDto = { + messages: Array; + /** + * Pass to fetch the previous (older) page + */ + nextCursor?: { + [key: string]: unknown; + } | null; + hasMore: boolean; +}; + +export type SyncChatsDto = { + /** + * Reconcile only this agent; omit for all agents + */ + agentId?: string; +}; + +export type SyncChatsResponseDto = { + scannedAgents: number; + scannedFiles: number; + upserted: number; + skipped: number; +}; + export type TelegramChannelConfigDto = { /** * Telegram bot HTTP API token (issued by @BotFather). @@ -838,96 +928,6 @@ export type ReportUsageDto = { }; }; -export type ChatSessionDto = { - id: string; - agentId: string; - channel: "bridle" | "telegram" | "slack" | "internal"; - externalUserId: string; - sessionKey: string; - title?: { - [key: string]: unknown; - } | null; - /** - * Last message text, truncated - */ - preview?: { - [key: string]: unknown; - } | null; - lastRole?: "user" | "assistant"; - /** - * Unix ms via ISO - */ - lastMessageAt: string; - /** - * Monotonic lifetime total - */ - messageCount: number; - userMessageCount: number; - summary?: { - [key: string]: unknown; - } | null; - summaryAt?: { - [key: string]: unknown; - } | null; - /** - * topics/sentiment/resolved/language - */ - insights?: { - [key: string]: unknown; - } | null; - archived: boolean; - createdAt: string; - updatedAt: string; -}; - -export type ChatListResponseDto = { - items: Array; - total: number; - page: number; - perPage: number; -}; - -export type ChatMessageDto = { - id: string; - role: - | "user" - | "assistant" - | "summary" - | "tool_call" - | "tool_result" - | "system"; - text: string; - /** - * Unix epoch ms - */ - ts: number; -}; - -export type ChatMessagesResponseDto = { - messages: Array; - /** - * Pass to fetch the previous (older) page - */ - nextCursor?: { - [key: string]: unknown; - } | null; - hasMore: boolean; -}; - -export type SyncChatsDto = { - /** - * Reconcile only this agent; omit for all agents - */ - agentId?: string; -}; - -export type SyncChatsResponseDto = { - scannedAgents: number; - scannedFiles: number; - upserted: number; - skipped: number; -}; - export type RunPaddockJudgeOverrideDto = { credentialIds?: Array; threshold?: number; @@ -2483,6 +2483,109 @@ export type FindDependentAgentsResponses = { 200: unknown; }; +export type GetChatsData = { + body?: never; + path?: never; + query?: { + /** + * Restrict to one agent + */ + agentId?: string; + channel?: "bridle" | "telegram" | "slack" | "internal"; + /** + * Matches title / preview / externalUserId + */ + search?: string; + /** + * Show archived sessions + */ + archived?: boolean; + /** + * Include internal (cron/heartbeat) sessions + */ + includeInternal?: boolean; + page?: number; + perPage?: number; + }; + url: "/chats"; +}; + +export type GetChatsResponses = { + 200: ChatListResponseDto; +}; + +export type GetChatsResponse = GetChatsResponses[keyof GetChatsResponses]; + +export type GetChatData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}"; +}; + +export type GetChatResponses = { + 200: ChatSessionDto; +}; + +export type GetChatResponse = GetChatResponses[keyof GetChatResponses]; + +export type GetChatMessagesData = { + body?: never; + path: { + id: string; + }; + query?: { + limit?: number; + /** + * Opaque cursor from a previous page + */ + cursor?: string; + /** + * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. + */ + types?: string; + }; + url: "/chats/{id}/messages"; +}; + +export type GetChatMessagesResponses = { + 200: ChatMessagesResponseDto; +}; + +export type GetChatMessagesResponse = + GetChatMessagesResponses[keyof GetChatMessagesResponses]; + +export type SyncChatsData = { + body: SyncChatsDto; + path?: never; + query?: never; + url: "/chats/sync"; +}; + +export type SyncChatsResponses = { + 200: SyncChatsResponseDto; +}; + +export type SyncChatsResponse = SyncChatsResponses[keyof SyncChatsResponses]; + +export type SummarizeChatData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}/summarize"; +}; + +export type SummarizeChatResponses = { + 200: ChatSessionDto; +}; + +export type SummarizeChatResponse = + SummarizeChatResponses[keyof SummarizeChatResponses]; + export type GetAgentChannelsData = { body?: never; path: { @@ -2935,93 +3038,6 @@ export type UsageControllerFindForCredentialResponses = { 200: unknown; }; -export type GetChatsData = { - body?: never; - path?: never; - query?: { - /** - * Restrict to one agent - */ - agentId?: string; - channel?: "bridle" | "telegram" | "slack" | "internal"; - /** - * Matches title / preview / externalUserId - */ - search?: string; - /** - * Show archived sessions - */ - archived?: boolean; - /** - * Include internal (cron/heartbeat) sessions - */ - includeInternal?: boolean; - page?: number; - perPage?: number; - }; - url: "/chats"; -}; - -export type GetChatsResponses = { - 200: ChatListResponseDto; -}; - -export type GetChatsResponse = GetChatsResponses[keyof GetChatsResponses]; - -export type GetChatData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/chats/{id}"; -}; - -export type GetChatResponses = { - 200: ChatSessionDto; -}; - -export type GetChatResponse = GetChatResponses[keyof GetChatResponses]; - -export type GetChatMessagesData = { - body?: never; - path: { - id: string; - }; - query?: { - limit?: number; - /** - * Opaque cursor from a previous page - */ - cursor?: string; - /** - * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. - */ - types?: string; - }; - url: "/chats/{id}/messages"; -}; - -export type GetChatMessagesResponses = { - 200: ChatMessagesResponseDto; -}; - -export type GetChatMessagesResponse = - GetChatMessagesResponses[keyof GetChatMessagesResponses]; - -export type SyncChatsData = { - body: SyncChatsDto; - path?: never; - query?: never; - url: "/chats/sync"; -}; - -export type SyncChatsResponses = { - 200: SyncChatsResponseDto; -}; - -export type SyncChatsResponse = SyncChatsResponses[keyof SyncChatsResponses]; - export type RancherControllerStatusData = { body?: never; path?: never; From ee28fe0ffdfa5148c015b2258fd82ea088748a1b Mon Sep 17 00:00:00 2001 From: maksym Date: Thu, 16 Jul 2026 11:31:47 +0200 Subject: [PATCH 06/21] =?UTF-8?q?feat(chat):=20=F0=9F=91=8D/=F0=9F=91=8E?= =?UTF-8?q?=20message=20feedback=20(Phase=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rate individual assistant messages in a chat transcript. - Feedback API (uses the Phase 1 ChatFeedback model): POST /chats/:id/feedback upserts the current user's rating (authorId = JWT sub, source = admin); DELETE /chats/:id/feedback/:messageId toggles it off; GET /chats/:id/feedback lists the current user's ratings. Unique per (session, message, author). - IChatGateway.upsertFeedback / deleteFeedback / listFeedbackByAuthor. - Admin: 👍/👎 on assistant messages in the transcript, highlighting the current rating; clicking the same rating again clears it. Regenerated admin + app OpenAPI clients (createChatFeedback / deleteChatFeedback / getMyChatFeedback). - 4 gateway specs (upsert / re-rate in place / toggle-off / author-scoping). Co-Authored-By: Claude Opus 4.8 --- .../chat/components/chatDetail/Provider.vue | 32 ++++++- .../chat/components/chatMessage/Bubble.vue | 36 ++++++- admin/slices/chat/stores/chat.ts | 25 ++++- .../api/data/repositories/api/schemas.gen.ts | 52 ++++++++++ .../api/data/repositories/api/sdk.gen.ts | 58 ++++++++++++ .../api/data/repositories/api/types.gen.ts | 75 +++++++++++++++ api/src/slices/chat/chat.controller.ts | 66 +++++++++++++ api/src/slices/chat/data/chat.gateway.spec.ts | 94 ++++++++++++++++++- api/src/slices/chat/data/chat.gateway.ts | 61 +++++++++++- api/src/slices/chat/domain/chat.gateway.ts | 20 ++++ api/src/slices/chat/domain/chat.types.ts | 21 +++++ api/src/slices/chat/dtos/chatFeedback.dto.ts | 28 ++++++ api/src/slices/chat/dtos/index.ts | 1 + .../api/data/repositories/api/schemas.gen.ts | 52 ++++++++++ .../api/data/repositories/api/sdk.gen.ts | 58 ++++++++++++ .../api/data/repositories/api/types.gen.ts | 75 +++++++++++++++ 16 files changed, 747 insertions(+), 7 deletions(-) create mode 100644 api/src/slices/chat/dtos/chatFeedback.dto.ts diff --git a/admin/slices/chat/components/chatDetail/Provider.vue b/admin/slices/chat/components/chatDetail/Provider.vue index e2f1221..9c5293f 100644 --- a/admin/slices/chat/components/chatDetail/Provider.vue +++ b/admin/slices/chat/components/chatDetail/Provider.vue @@ -81,7 +81,29 @@ async function loadOlder() { } } -onMounted(loadLatest); +// Current user's 👍/👎 per messageId. +const feedbackByMsg = ref>({}); +async function loadFeedback() { + const fb = await store.feedback(props.id); + const map: Record = {}; + for (const f of fb) map[f.messageId] = f.rating; + feedbackByMsg.value = map; +} +async function onRate(messageId: string, rating: 1 | -1) { + const current = feedbackByMsg.value[messageId]; + if (current === rating) { + await store.unrate(props.id, messageId); // toggle off + delete feedbackByMsg.value[messageId]; + } else { + await store.rate(props.id, messageId, rating); + feedbackByMsg.value[messageId] = rating; + } +} + +onMounted(() => { + void loadLatest(); + void loadFeedback(); +}); watch(showTools, loadLatest); // re-fetch from latest when toggling tool events const who = computed( @@ -171,7 +193,13 @@ function fmt(iso?: string | null): string { No messages in this session. - + diff --git a/admin/slices/chat/components/chatMessage/Bubble.vue b/admin/slices/chat/components/chatMessage/Bubble.vue index 396c0e6..6da9700 100644 --- a/admin/slices/chat/components/chatMessage/Bubble.vue +++ b/admin/slices/chat/components/chatMessage/Bubble.vue @@ -1,14 +1,16 @@ + + diff --git a/app/slices/chat/components/chatList/Card.vue b/app/slices/chat/components/chatList/Card.vue new file mode 100644 index 0000000..6f200fd --- /dev/null +++ b/app/slices/chat/components/chatList/Card.vue @@ -0,0 +1,63 @@ + + + diff --git a/app/slices/chat/components/chatList/Provider.vue b/app/slices/chat/components/chatList/Provider.vue new file mode 100644 index 0000000..342df22 --- /dev/null +++ b/app/slices/chat/components/chatList/Provider.vue @@ -0,0 +1,74 @@ + + + diff --git a/app/slices/chat/components/chatMessage/Bubble.vue b/app/slices/chat/components/chatMessage/Bubble.vue new file mode 100644 index 0000000..67289df --- /dev/null +++ b/app/slices/chat/components/chatMessage/Bubble.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/app/slices/chat/nuxt.config.ts b/app/slices/chat/nuxt.config.ts new file mode 100644 index 0000000..837f639 --- /dev/null +++ b/app/slices/chat/nuxt.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); + +export default defineNuxtConfig({ + alias: { + '#chat': currentDir, + }, + imports: { + dirs: [`${currentDir}/stores`], + }, +}); diff --git a/app/slices/chat/pages/chats/[id].vue b/app/slices/chat/pages/chats/[id].vue new file mode 100644 index 0000000..4df5269 --- /dev/null +++ b/app/slices/chat/pages/chats/[id].vue @@ -0,0 +1,8 @@ + + + diff --git a/app/slices/chat/pages/chats/index.vue b/app/slices/chat/pages/chats/index.vue new file mode 100644 index 0000000..5596bef --- /dev/null +++ b/app/slices/chat/pages/chats/index.vue @@ -0,0 +1,5 @@ + diff --git a/app/slices/chat/stores/chat.ts b/app/slices/chat/stores/chat.ts new file mode 100644 index 0000000..0df2878 --- /dev/null +++ b/app/slices/chat/stores/chat.ts @@ -0,0 +1,96 @@ +import { ChatsService } from '#api'; + +// The API wraps every response in { success, data }; unwrap to the payload. +interface IEnvelope { + success?: boolean; + data?: T; +} +function unwrap(body: unknown): T | null { + if (body && typeof body === 'object' && 'data' in (body as IEnvelope)) { + return ((body as IEnvelope).data ?? null) as T | null; + } + return (body ?? null) as T | null; +} + +// One of the current user's own chat sessions (index metadata). Server-scoped +// to the caller's JWT — the app never passes a user id. +export interface IChatSession { + id: string; + channel: string; + externalUserId: string; + sessionKey: string; + title: string | null; + preview: string | null; + lastRole: string | null; + lastMessageAt: string; + messageCount: number; + userMessageCount: number; + summary: string | null; + archived: boolean; + createdAt: string; + updatedAt: string; +} + +// End users only ever see conversational roles — tool events are filtered +// server-side; `summary` marks where compaction folded older turns. +export type ChatMessageRole = 'user' | 'assistant' | 'summary'; + +export interface IChatMessage { + id: string; + role: ChatMessageRole; + text: string; + ts: number; +} + +export interface IChatListResult { + items: IChatSession[]; + total: number; + page: number; + perPage: number; +} + +export interface IChatMessagesResult { + messages: IChatMessage[]; + nextCursor: string | null; + hasMore: boolean; +} + +export interface IChatMessagesQuery { + limit?: number; + cursor?: string; +} + +export const useChatStore = defineStore('chat', () => { + async function listMine(page = 1, perPage = 50): Promise { + const res = await ChatsService.getMyChats({ query: { page, perPage } }); + return ( + unwrap(res.data) ?? { + items: [], + total: 0, + page, + perPage, + } + ); + } + + async function getMine(id: string): Promise { + const res = await ChatsService.getMyChat({ path: { id } }); + return unwrap(res.data); + } + + async function messages( + id: string, + query: IChatMessagesQuery = {}, + ): Promise { + const res = await ChatsService.getMyChatMessages({ path: { id }, query }); + return ( + unwrap(res.data) ?? { + messages: [], + nextCursor: null, + hasMore: false, + } + ); + } + + return { listMine, getMine, messages }; +}); diff --git a/app/slices/common/components/layout/Provider.vue b/app/slices/common/components/layout/Provider.vue index 06e9c77..548cbee 100644 --- a/app/slices/common/components/layout/Provider.vue +++ b/app/slices/common/components/layout/Provider.vue @@ -21,6 +21,14 @@ > Agents + + History +
diff --git a/app/slices/setup/api/data/repositories/api/sdk.gen.ts b/app/slices/setup/api/data/repositories/api/sdk.gen.ts index 5d64a81..21c76bf 100644 --- a/app/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/app/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -132,6 +132,12 @@ import type { DeleteChatFeedbackData, DeleteChatFeedbackResponse, ExportChatData, + GetMyChatsData, + GetMyChatsResponse, + GetMyChatData, + GetMyChatResponse, + GetMyChatMessagesData, + GetMyChatMessagesResponse, GetAgentChannelsData, GetAgentChannelsResponse, SetAgentChannelsData, @@ -2129,6 +2135,54 @@ export class ChatsService { ...options, }); } + + /** + * List the current user's own chat sessions, newest first. + */ + public static getMyChats( + options?: Options, + ) { + return (options?.client ?? _heyApiClient).get< + GetMyChatsResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats", + ...options, + }); + } + + /** + * Get one of the current user's own chat sessions. + */ + public static getMyChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetMyChatResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}", + ...options, + }); + } + + /** + * Replay one of the current user's own chats, tail-first. `summary` markers are shown inline; tool events are never exposed to end users. + */ + public static getMyChatMessages( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetMyChatMessagesResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}/messages", + ...options, + }); + } } export class UsersService { diff --git a/app/slices/setup/api/data/repositories/api/types.gen.ts b/app/slices/setup/api/data/repositories/api/types.gen.ts index 6f67ee1..f51f91d 100644 --- a/app/slices/setup/api/data/repositories/api/types.gen.ts +++ b/app/slices/setup/api/data/repositories/api/types.gen.ts @@ -2679,6 +2679,67 @@ export type ExportChatResponses = { 200: unknown; }; +export type GetMyChatsData = { + body?: never; + path?: never; + query?: { + /** + * Include archived chats + */ + archived?: boolean; + page?: number; + perPage?: number; + }; + url: "/me/chats"; +}; + +export type GetMyChatsResponses = { + 200: ChatListResponseDto; +}; + +export type GetMyChatsResponse = GetMyChatsResponses[keyof GetMyChatsResponses]; + +export type GetMyChatData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/me/chats/{id}"; +}; + +export type GetMyChatResponses = { + 200: ChatSessionDto; +}; + +export type GetMyChatResponse = GetMyChatResponses[keyof GetMyChatResponses]; + +export type GetMyChatMessagesData = { + body?: never; + path: { + id: string; + }; + query?: { + limit?: number; + /** + * Opaque cursor from a previous page + */ + cursor?: string; + /** + * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. + */ + types?: string; + }; + url: "/me/chats/{id}/messages"; +}; + +export type GetMyChatMessagesResponses = { + 200: ChatMessagesResponseDto; +}; + +export type GetMyChatMessagesResponse = + GetMyChatMessagesResponses[keyof GetMyChatMessagesResponses]; + export type GetAgentChannelsData = { body?: never; path: { diff --git a/app/slices/user/auth/middleware/auth.global.ts b/app/slices/user/auth/middleware/auth.global.ts index 6517a2a..5b99a74 100644 --- a/app/slices/user/auth/middleware/auth.global.ts +++ b/app/slices/user/auth/middleware/auth.global.ts @@ -4,10 +4,10 @@ * - /login self * - /register self (gated by setting on the server side) * - * Anything under /agents/** requires login. The auth plugin awaits init() - * before navigation kicks in, so isHydrated is always true when this runs. + * Anything under /agents/** or /chats/** requires login. The auth plugin awaits + * init() before navigation kicks in, so isHydrated is always true when this runs. */ -const PROTECTED_PREFIXES = ['/agents']; +const PROTECTED_PREFIXES = ['/agents', '/chats']; const AUTH_PAGES = new Set(['/login', '/register']); export default defineNuxtRouteMiddleware((to) => { From e029e37eb1021bab753254c6739ebd29eb3e44b9 Mon Sep 17 00:00:00 2001 From: maksym Date: Thu, 16 Jul 2026 16:48:42 +0200 Subject: [PATCH 12/21] fix(llm): send Claude subscription OAuth tokens via Bearer + claude-code beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sk-ant-oat… tokens (Claude subscription OAuth — what the agents run on) are rejected on the x-api-key header (401 invalid x-api-key). Ranch sent every Anthropic request that way, so chat summaries, the credential health check, and scenario generation all failed on the exact token the bots use fine. Add anthropicAuthHeaders() (mirrors the runtime's ClaudeRepository): OAuth tokens go as Authorization: Bearer + anthropic-beta oauth,claude-code; standard API keys keep x-api-key. Applied to all three callers. Co-Authored-By: Claude Opus 4.8 --- .../slices/chat/domain/chatInsight.service.ts | 6 +++-- api/src/slices/llm/data/llmHealth.gateway.ts | 5 +++- api/src/slices/llm/domain/index.ts | 1 + api/src/slices/llm/domain/llm.utils.spec.ts | 25 +++++++++++++++++++ api/src/slices/llm/domain/llm.utils.ts | 23 +++++++++++++++++ .../data/scenarioGenerator.gateway.ts | 6 +++-- 6 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 api/src/slices/llm/domain/llm.utils.spec.ts diff --git a/api/src/slices/chat/domain/chatInsight.service.ts b/api/src/slices/chat/domain/chatInsight.service.ts index c4acad4..86924b9 100644 --- a/api/src/slices/chat/domain/chatInsight.service.ts +++ b/api/src/slices/chat/domain/chatInsight.service.ts @@ -7,7 +7,7 @@ import { OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; -import { ILlmGateway } from '#/llm/domain'; +import { ILlmGateway, anthropicAuthHeaders } from '#/llm/domain'; import type { ILlmCredentialData } from '#/llm/domain'; import { TranscriptReaderService } from '#/agent/file/domain'; import { IChatGateway } from './chat.gateway'; @@ -191,8 +191,10 @@ export class ChatInsightService implements OnModuleInit, OnModuleDestroy { method: 'POST', headers: { 'content-type': 'application/json', - 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', + // OAuth subscription tokens (sk-ant-oat…, what the bots run on) must go + // as Bearer + claude-code beta, not x-api-key — see anthropicAuthHeaders. + ...anthropicAuthHeaders(apiKey), }, body: JSON.stringify({ model, diff --git a/api/src/slices/llm/data/llmHealth.gateway.ts b/api/src/slices/llm/data/llmHealth.gateway.ts index 56ffe5f..61325bb 100644 --- a/api/src/slices/llm/data/llmHealth.gateway.ts +++ b/api/src/slices/llm/data/llmHealth.gateway.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ILlmCredentialData } from '../domain/llm.types'; +import { anthropicAuthHeaders } from '../domain/llm.utils'; import { ILlmHealthGateway, ILlmHealthCheckResult, @@ -61,8 +62,10 @@ export class LlmHealthGateway extends ILlmHealthGateway { method: 'POST', headers: { 'content-type': 'application/json', - 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', + // OAuth subscription tokens (sk-ant-oat…) need Bearer + claude-code + // beta, not x-api-key — else a working bot credential reads as invalid. + ...anthropicAuthHeaders(apiKey), }, body: JSON.stringify({ model, diff --git a/api/src/slices/llm/domain/index.ts b/api/src/slices/llm/domain/index.ts index f5b25e9..ab09ccd 100644 --- a/api/src/slices/llm/domain/index.ts +++ b/api/src/slices/llm/domain/index.ts @@ -1,3 +1,4 @@ export * from './llm.types'; +export * from './llm.utils'; export { ILlmGateway } from './llm.gateway'; export { ILlmHealthGateway, ILlmHealthCheckResult } from './llmHealth.gateway'; diff --git a/api/src/slices/llm/domain/llm.utils.spec.ts b/api/src/slices/llm/domain/llm.utils.spec.ts new file mode 100644 index 0000000..4c6194a --- /dev/null +++ b/api/src/slices/llm/domain/llm.utils.spec.ts @@ -0,0 +1,25 @@ +import { anthropicAuthHeaders, normalizeCredential } from './llm.utils'; + +describe('anthropicAuthHeaders', () => { + it('sends an OAuth subscription token as Bearer + claude-code beta (never x-api-key)', () => { + const h = anthropicAuthHeaders('sk-ant-oat01-abc123'); + expect(h.authorization).toBe('Bearer sk-ant-oat01-abc123'); + expect(h['anthropic-beta']).toBe('oauth-2025-04-20,claude-code-20250219'); + expect(h['x-api-key']).toBeUndefined(); + }); + + it('sends a standard API key via x-api-key (no Bearer, no beta)', () => { + const h = anthropicAuthHeaders('sk-ant-api03-xyz789'); + expect(h['x-api-key']).toBe('sk-ant-api03-xyz789'); + expect(h.authorization).toBeUndefined(); + expect(h['anthropic-beta']).toBeUndefined(); + }); +}); + +describe('normalizeCredential', () => { + it('strips an env-var prefix and surrounding quotes', () => { + expect(normalizeCredential('LLM_API_KEY="sk-ant-oat01-x"')).toBe( + 'sk-ant-oat01-x', + ); + }); +}); diff --git a/api/src/slices/llm/domain/llm.utils.ts b/api/src/slices/llm/domain/llm.utils.ts index 2ff087b..fcfc571 100644 --- a/api/src/slices/llm/domain/llm.utils.ts +++ b/api/src/slices/llm/domain/llm.utils.ts @@ -22,3 +22,26 @@ export function normalizeCredential(raw: string): string { } return v.trim(); } + +/** + * Anthropic accepts two credential types over DIFFERENT transports — sending + * one the other's way is a hard 401 (`invalid x-api-key`): + * - `sk-ant-oat…` — a Claude **subscription OAuth token** (what our agents/bots + * run on). Must be sent as a Bearer token with the Claude Code beta headers, + * NOT via `x-api-key`. + * - anything else — a standard API key (`sk-ant-api…`), sent via `x-api-key`. + * + * Returns only the auth-related headers so callers merge them into their own + * (`content-type`, `anthropic-version`). Mirrors the runtime's ClaudeRepository + * (`data/repositories/claude`) so ranch-side calls (health check, chat + * summaries, scenario generation) work with the exact credential the bots use. + */ +export function anthropicAuthHeaders(apiKey: string): Record { + if (apiKey.startsWith('sk-ant-oat')) { + return { + authorization: `Bearer ${apiKey}`, + 'anthropic-beta': 'oauth-2025-04-20,claude-code-20250219', + }; + } + return { 'x-api-key': apiKey }; +} diff --git a/api/src/slices/paddock/scenario/data/scenarioGenerator.gateway.ts b/api/src/slices/paddock/scenario/data/scenarioGenerator.gateway.ts index 56a5d29..5d81df0 100644 --- a/api/src/slices/paddock/scenario/data/scenarioGenerator.gateway.ts +++ b/api/src/slices/paddock/scenario/data/scenarioGenerator.gateway.ts @@ -4,7 +4,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { ILlmGateway } from '#/llm/domain'; +import { ILlmGateway, anthropicAuthHeaders } from '#/llm/domain'; import { IPaddockScenarioGeneratorGateway, IGeneratePaddockScenarioInput, @@ -143,8 +143,10 @@ export class PaddockScenarioGeneratorGateway extends IPaddockScenarioGeneratorGa method: 'POST', headers: { 'content-type': 'application/json', - 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', + // OAuth subscription tokens (sk-ant-oat…) need Bearer + claude-code beta, + // not x-api-key — see anthropicAuthHeaders. + ...anthropicAuthHeaders(apiKey), }, body: JSON.stringify({ model, From 51506ee6168086fede6ff437c5205e5fec31dbdb Mon Sep 17 00:00:00 2001 From: maksym Date: Thu, 16 Jul 2026 16:48:42 +0200 Subject: [PATCH 13/21] fix(bridle): stable client identity for HTTP chat so approval/history persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /:agentId/message[/sync] minted a fresh sync- clientId per request, so the agent runtime — which keys access-approval and session history on that id — saw a new, unapproved user every message, and the 'send the owner your code' prompt never cleared. Verify the Bearer JWT the app already sends and use its sub (or admin), like the WS client handler; fall back to a throwaway id only for anonymous callers. Co-Authored-By: Claude Opus 4.8 --- api/src/slices/bridle/bridle.controller.ts | 32 ++++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/api/src/slices/bridle/bridle.controller.ts b/api/src/slices/bridle/bridle.controller.ts index 6f259be..c86f10a 100644 --- a/api/src/slices/bridle/bridle.controller.ts +++ b/api/src/slices/bridle/bridle.controller.ts @@ -19,6 +19,7 @@ import { ApiOkResponse, ApiQuery, } from '@nestjs/swagger'; +import { JwtService } from '@nestjs/jwt'; import { IBridleGateway, buildParts } from './domain'; import { SendMessageDto, @@ -42,12 +43,38 @@ export class BridleController { constructor( private readonly hub: IBridleGateway, + private readonly jwt: JwtService, @Inject(forwardRef(() => IFileGateway)) private readonly fileGateway: IFileGateway, @Inject(forwardRef(() => TranscriptReaderService)) private readonly transcriptReader: TranscriptReaderService, ) {} + /** + * Resolve a STABLE client identity for HTTP chat calls, the same way the WS + * client handler does: verify the Bearer JWT the app already sends and use its + * `sub` (or `admin` for owners/admins). A stable id is essential — the agent + * runtime keys access-approval AND session history on this id, so a fresh id + * per request re-triggers the "send the owner your code" flow on every message + * and scatters history across throwaway channels. Returns null for anonymous + * callers (no/invalid token) → the caller mints a per-request throwaway id. + */ + private resolveClientId(req: Record): string | null { + const headers = req.headers as Record; + const [scheme, token] = (headers?.authorization ?? '').split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) return null; + try { + const payload = this.jwt.verify>(token); + const roles = payload.roles as string[] | undefined; + const isAdmin = + Array.isArray(roles) && + (roles.includes('Owner') || roles.includes('Admin')); + return isAdmin ? 'admin' : ((payload.sub as string) ?? null); + } catch { + return null; + } + } + @ApiOperation({ description: 'Send a message to a agent (HTTP fallback — fire & forget)', operationId: 'sendBridleMessage', @@ -61,8 +88,7 @@ export class BridleController { @Req() req: Record, @Body() body: SendMessageDto, ) { - const user = req.user as Record | undefined; - const clientId = (user?.id as string) ?? 'http-' + crypto.randomUUID(); + const clientId = this.resolveClientId(req) ?? 'http-' + crypto.randomUUID(); const parts = body.parts ?? buildParts(body.text, body.images); this.hub.sendToAgent(clientId, agentId, body.text, parts); return { ok: true }; @@ -81,7 +107,7 @@ export class BridleController { @Req() req: Record, @Body() body: SendMessageDto, ) { - const clientId = 'sync-' + crypto.randomUUID(); + const clientId = this.resolveClientId(req) ?? 'sync-' + crypto.randomUUID(); const chunks: string[] = []; return new Promise((resolve) => { From 74241d8f283c16c50d480a98f0d58e0ad048f1e7 Mon Sep 17 00:00:00 2001 From: maksym Date: Thu, 16 Jul 2026 16:48:42 +0200 Subject: [PATCH 14/21] feat(chat): surface sentiment/resolved/language beside the insights header (admin) Move the three meta insight badges up next to 'Summary & insights' (apart from the topic tags, which keep their own row) and capitalize each. Co-Authored-By: Claude Opus 4.8 --- .../chat/components/chatDetail/Provider.vue | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/admin/slices/chat/components/chatDetail/Provider.vue b/admin/slices/chat/components/chatDetail/Provider.vue index 8dd925f..5b21579 100644 --- a/admin/slices/chat/components/chatDetail/Provider.vue +++ b/admin/slices/chat/components/chatDetail/Provider.vue @@ -27,9 +27,9 @@ async function onSummarize() { } } -const sentimentVariant: Record = { +const sentimentVariant: Record = { positive: 'default', - neutral: 'secondary', + neutral: 'destructive', negative: 'outline', mixed: 'secondary', }; @@ -146,7 +146,24 @@ function fmt(iso?: string | null): string {
- Summary & insights +
+ Summary & insights + + +
@@ -160,21 +177,19 @@ function fmt(iso?: string | null): string {

No summary yet — click Summarize to generate one.

-
+ +
{{ topic }} - - {{ session.insights.sentiment }} - - - {{ session.insights.resolved ? 'resolved' : 'unresolved' }} - - {{ session.insights.language }}
From 3b34ed2a103c8c6c5c4b00ffdd6403b926b73807 Mon Sep 17 00:00:00 2001 From: maksym Date: Thu, 16 Jul 2026 16:54:35 +0200 Subject: [PATCH 15/21] fix(app): stop duplicate requests when navigating in/out of agent chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default layout rendered (NuxtPage) in both branches of a v-if/v-else keyed on isFlush. isFlush flips only on entering/leaving /agents/:id, so the branch swap tore down and recreated the page subtree — remounting the page and re-firing every useAsyncData request (doubled agent/chats/me/agents calls). Use one wrapper element with a toggled class. Co-Authored-By: Claude Opus 4.8 --- app/slices/common/components/layout/Provider.vue | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/slices/common/components/layout/Provider.vue b/app/slices/common/components/layout/Provider.vue index 548cbee..671cc99 100644 --- a/app/slices/common/components/layout/Provider.vue +++ b/app/slices/common/components/layout/Provider.vue @@ -68,10 +68,18 @@
-
- -
-
+ +
From 89ca303e2933ac6e419874bf753524bed16c398b Mon Sep 17 00:00:00 2001 From: maksym Date: Thu, 16 Jul 2026 17:19:39 +0200 Subject: [PATCH 16/21] =?UTF-8?q?feat(chat):=20my-history=20resolves=20own?= =?UTF-8?q?er=E2=86=92admin=20identity=20+=20self-service=20/me/chats/sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners/admins chat under the shared bridle 'admin' channel (that's how the agent recognizes them as admin — runtime adminIds includes the literal 'admin'), not under their JWT sub. So /me/chats scoped strictly to sub showed them nothing. Resolve the caller's OWN bridle identity the same way the bridle client/HTTP handlers do — isAdmin ? 'admin' : sub — for list, detail, messages, and sync. Add POST /me/chats/sync (syncMyChats): a JWT-scoped, non-admin reconcile that pulls only the caller's own sessions from S3 into the index — a manual fallback when realtime indexing hasn't caught up. App gets a Sync button on the history list. Co-Authored-By: Claude Opus 4.8 --- .../slices/chat/domain/chatSync.service.ts | 37 ++++++- api/src/slices/chat/myChat.controller.spec.ts | 101 +++++++++++++----- api/src/slices/chat/myChat.controller.ts | 56 +++++++--- .../chat/components/chatList/Provider.vue | 40 +++++-- app/slices/chat/stores/chat.ts | 18 +++- .../api/data/repositories/api/sdk.gen.ts | 22 ++++ .../api/data/repositories/api/types.gen.ts | 14 +++ 7 files changed, 241 insertions(+), 47 deletions(-) diff --git a/api/src/slices/chat/domain/chatSync.service.ts b/api/src/slices/chat/domain/chatSync.service.ts index c0fc36f..73d2a78 100644 --- a/api/src/slices/chat/domain/chatSync.service.ts +++ b/api/src/slices/chat/domain/chatSync.service.ts @@ -81,9 +81,43 @@ export class ChatSyncService implements OnModuleInit, OnModuleDestroy { return result; } + /** + * Reconcile only ONE end user's own bridle sessions (App "my history" — + * `POST /me/chats/sync`). Scans the agent(s) but reconciles just files whose + * parsed externalUserId matches: a self-service, non-admin equivalent of + * syncAll. Not guarded by `running` (scoped + read-only), so it can run + * alongside the interval reconcile. + */ + async syncForExternalUser( + externalUserId: string, + agentId?: string, + ): Promise { + const result: IChatSyncResult = { + scannedAgents: 0, + scannedFiles: 0, + upserted: 0, + skipped: 0, + }; + const agentIds = agentId + ? [agentId] + : (await this.agents.findAll()).map((a) => a.id); + const mine = (m: { channel: string; externalUserId: string }): boolean => + m.channel === 'bridle' && m.externalUserId === externalUserId; + for (const id of agentIds) { + result.scannedAgents++; + await this.syncAgent(id, result, mine); + } + this.logger.log( + `user reconcile (${externalUserId}) done: agents=${result.scannedAgents} ` + + `files=${result.scannedFiles} upserted=${result.upserted} skipped=${result.skipped}`, + ); + return result; + } + private async syncAgent( agentId: string, result: IChatSyncResult, + filter?: (meta: { channel: string; externalUserId: string }) => boolean, ): Promise { let nodes; try { @@ -109,9 +143,10 @@ export class ChatSyncService implements OnModuleInit, OnModuleDestroy { ); for (const node of sessionFiles) { - result.scannedFiles++; const meta = this.parseName(node.path); if (!meta) continue; + if (filter && !filter(meta)) continue; + result.scannedFiles++; if (sizeByKey.get(meta.sessionKey) === node.size) { result.skipped++; diff --git a/api/src/slices/chat/myChat.controller.spec.ts b/api/src/slices/chat/myChat.controller.spec.ts index 506f61d..612b1c3 100644 --- a/api/src/slices/chat/myChat.controller.spec.ts +++ b/api/src/slices/chat/myChat.controller.spec.ts @@ -1,9 +1,10 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Request } from 'express'; import { MyChatController } from './myChat.controller'; -import { IChatGateway, IChatSessionData } from './domain'; +import { IChatGateway, IChatSessionData, ChatSyncService } from './domain'; import { TranscriptReaderService } from '#/agent/file/domain'; import { IAuthTokenPayload } from '#/user/auth/domain/auth.types'; +import { UserRoleTypes } from '#/user/user/domain'; const SUB = 'user-1'; @@ -46,15 +47,34 @@ function readerStub(read?: jest.Mock) { } as unknown as TranscriptReaderService; } -const req = (sub?: string) => - ({ user: sub ? ({ sub } as IAuthTokenPayload) : undefined }) as Request & { - user?: IAuthTokenPayload; - }; +function syncStub(fn?: jest.Mock) { + const syncForExternalUser = + fn ?? + jest.fn(async () => ({ + scannedAgents: 1, + scannedFiles: 1, + upserted: 1, + skipped: 0, + })); + const sync = { syncForExternalUser } as unknown as ChatSyncService; + return { sync, syncForExternalUser }; +} + +const req = (sub?: string, roles: UserRoleTypes[] = []) => + ({ + user: sub ? ({ sub, roles, email: '' } as IAuthTokenPayload) : undefined, + }) as Request & { user?: IAuthTokenPayload }; + +function makeController(findById: jest.Mock, read?: jest.Mock) { + const { chats, list } = makeChats(findById); + const { sync, syncForExternalUser } = syncStub(); + const ctrl = new MyChatController(chats, readerStub(read), sync); + return { ctrl, list, syncForExternalUser }; +} describe('MyChatController.list', () => { - it('scopes the query to the caller (bridle + their sub)', async () => { - const { chats, list } = makeChats(jest.fn()); - const ctrl = new MyChatController(chats, readerStub()); + it('scopes the query to a regular caller (bridle + their sub)', async () => { + const { ctrl, list } = makeController(jest.fn()); const res = await ctrl.list({ page: 2, perPage: 10 }, req(SUB)); expect(list).toHaveBeenCalledWith({ channel: 'bridle', @@ -66,9 +86,16 @@ describe('MyChatController.list', () => { expect(res).toEqual({ items: [OWNED], total: 1, page: 2, perPage: 10 }); }); + it("scopes an owner/admin to the shared 'admin' channel", async () => { + const { ctrl, list } = makeController(jest.fn()); + await ctrl.list({}, req(SUB, [UserRoleTypes.Owner])); + expect(list).toHaveBeenCalledWith( + expect.objectContaining({ channel: 'bridle', externalUserId: 'admin' }), + ); + }); + it('rejects an unauthenticated request', async () => { - const { chats } = makeChats(jest.fn()); - const ctrl = new MyChatController(chats, readerStub()); + const { ctrl } = makeController(jest.fn()); await expect(ctrl.list({}, req(undefined))).rejects.toBeInstanceOf( ForbiddenException, ); @@ -76,15 +103,21 @@ describe('MyChatController.list', () => { }); describe('MyChatController.detail', () => { - it('returns the session when the caller owns it', async () => { - const { chats } = makeChats(jest.fn(async () => OWNED)); - const ctrl = new MyChatController(chats, readerStub()); + it('returns the session when a regular caller owns it', async () => { + const { ctrl } = makeController(jest.fn(async () => OWNED)); await expect(ctrl.detail('c1', req(SUB))).resolves.toEqual(OWNED); }); + it("lets an owner/admin read the shared 'admin' session", async () => { + const adminChat = { ...OWNED, externalUserId: 'admin' }; + const { ctrl } = makeController(jest.fn(async () => adminChat)); + await expect( + ctrl.detail('c1', req(SUB, [UserRoleTypes.Admin])), + ).resolves.toEqual(adminChat); + }); + it('404s when the chat belongs to someone else (no existence leak)', async () => { - const { chats } = makeChats(jest.fn(async () => OWNED)); - const ctrl = new MyChatController(chats, readerStub()); + const { ctrl } = makeController(jest.fn(async () => OWNED)); await expect(ctrl.detail('c1', req('someone-else'))).rejects.toBeInstanceOf( NotFoundException, ); @@ -92,16 +125,14 @@ describe('MyChatController.detail', () => { it('404s a non-bridle session even with a matching externalUserId', async () => { const telegram = { ...OWNED, channel: 'telegram' }; - const { chats } = makeChats(jest.fn(async () => telegram)); - const ctrl = new MyChatController(chats, readerStub()); + const { ctrl } = makeController(jest.fn(async () => telegram)); await expect(ctrl.detail('c1', req(SUB))).rejects.toBeInstanceOf( NotFoundException, ); }); it('404s a missing chat', async () => { - const { chats } = makeChats(jest.fn(async () => null)); - const ctrl = new MyChatController(chats, readerStub()); + const { ctrl } = makeController(jest.fn(async () => null)); await expect(ctrl.detail('nope', req(SUB))).rejects.toBeInstanceOf( NotFoundException, ); @@ -113,8 +144,10 @@ describe('MyChatController.messages', () => { const read = jest.fn(async () => [ { id: 'm1', role: 'user', text: 'hi', ts: 1 }, ]); - const { chats } = makeChats(jest.fn(async () => OWNED)); - const ctrl = new MyChatController(chats, readerStub(read)); + const { ctrl } = makeController( + jest.fn(async () => OWNED), + read, + ); const res = await ctrl.messages('c1', {}, req(SUB)); expect(read).toHaveBeenCalledWith( 'a1', @@ -126,8 +159,10 @@ describe('MyChatController.messages', () => { it("does not read another user's transcript", async () => { const read = jest.fn(async () => []); - const { chats } = makeChats(jest.fn(async () => OWNED)); - const ctrl = new MyChatController(chats, readerStub(read)); + const { ctrl } = makeController( + jest.fn(async () => OWNED), + read, + ); await expect( ctrl.messages('c1', {}, req('someone-else')), ).rejects.toBeInstanceOf(NotFoundException); @@ -138,9 +173,25 @@ describe('MyChatController.messages', () => { const read = jest.fn(async () => { throw new Error('gone'); }); - const { chats } = makeChats(jest.fn(async () => OWNED)); - const ctrl = new MyChatController(chats, readerStub(read)); + const { ctrl } = makeController( + jest.fn(async () => OWNED), + read, + ); const res = await ctrl.messages('c1', {}, req(SUB)); expect(res).toEqual({ messages: [], nextCursor: null, hasMore: false }); }); }); + +describe('MyChatController.syncMine', () => { + it('reconciles only the caller (regular user → their sub)', async () => { + const { ctrl, syncForExternalUser } = makeController(jest.fn()); + await ctrl.syncMine({ agentId: 'a1' }, req(SUB)); + expect(syncForExternalUser).toHaveBeenCalledWith(SUB, 'a1'); + }); + + it("reconciles the 'admin' channel for an owner/admin", async () => { + const { ctrl, syncForExternalUser } = makeController(jest.fn()); + await ctrl.syncMine({}, req(SUB, [UserRoleTypes.Owner])); + expect(syncForExternalUser).toHaveBeenCalledWith('admin', undefined); + }); +}); diff --git a/api/src/slices/chat/myChat.controller.ts b/api/src/slices/chat/myChat.controller.ts index 4351992..675d23e 100644 --- a/api/src/slices/chat/myChat.controller.ts +++ b/api/src/slices/chat/myChat.controller.ts @@ -1,10 +1,12 @@ import { + Body, Controller, ForbiddenException, Get, Logger, NotFoundException, Param, + Post, Query, Req, UseGuards, @@ -18,25 +20,24 @@ import { import { Request } from 'express'; import { JwtAuthGuard } from '#/user/auth/guards'; import { IAuthTokenPayload } from '#/user/auth/domain/auth.types'; +import { UserRoleTypes } from '#/user/user/domain'; import { TranscriptMessage, TranscriptReaderService, } from '#/agent/file/domain'; -import { IChatGateway, IChatSessionData } from './domain'; +import { IChatGateway, IChatSessionData, ChatSyncService } from './domain'; import { ChatListResponseDto, ChatMessagesQueryDto, ChatMessagesResponseDto, ChatSessionDto, MyChatsQueryDto, + SyncChatsDto, + SyncChatsResponseDto, } from './dtos'; type AuthedRequest = Request & { user?: IAuthTokenPayload }; -// End users only ever own their web/Bridle chats: a logged-in browser's -// clientId is the JWT `sub` (bridleClientWs.handler), so the session lives at -// `bridle:`. Telegram/slack chats key on the channel's own id, never the -// JWT sub — so scoping to bridle + sub is the exact "mine" set. const OWN_CHANNEL = 'bridle'; // End users never see tool-call debug events — hard-capped, no `types` override. @@ -50,7 +51,7 @@ const USER_TYPES: TranscriptMessage['role'][] = [ * End-user-facing "my chat history" (Phase 6). Unlike the admin ChatController * (Owner/Admin, sees every chat), this is JWT-only (any authenticated user) and * scoped server-side to the caller's OWN bridle sessions — a user can never - * read another user's chat, and the id in the path is ownership-checked. + * read another user's chat, and every id in the path is ownership-checked. */ @ApiTags('chats') @ApiBearerAuth() @@ -62,6 +63,7 @@ export class MyChatController { constructor( private readonly chats: IChatGateway, private readonly reader: TranscriptReaderService, + private readonly sync: ChatSyncService, ) {} @ApiOperation({ @@ -74,12 +76,11 @@ export class MyChatController { @Query() q: MyChatsQueryDto, @Req() req: AuthedRequest, ): Promise { - const sub = this.requireSub(req); const page = q.page ?? 1; const perPage = q.perPage ?? 50; const { items, total } = await this.chats.list({ channel: OWN_CHANNEL, - externalUserId: sub, + externalUserId: this.ownExternalId(req), archived: q.archived ?? false, page, perPage, @@ -97,7 +98,7 @@ export class MyChatController { @Param('id') id: string, @Req() req: AuthedRequest, ): Promise { - return this.requireOwned(id, this.requireSub(req)); + return this.requireOwned(id, this.ownExternalId(req)); } @ApiOperation({ @@ -113,7 +114,7 @@ export class MyChatController { @Query() q: ChatMessagesQueryDto, @Req() req: AuthedRequest, ): Promise { - const session = await this.requireOwned(id, this.requireSub(req)); + const session = await this.requireOwned(id, this.ownExternalId(req)); const path = `data/sessions/${session.sessionKey}.jsonl`; let all: TranscriptMessage[]; @@ -132,10 +133,37 @@ export class MyChatController { return TranscriptReaderService.page(all, q.cursor, q.limit ?? 50); } - private requireSub(req: AuthedRequest): string { + @ApiOperation({ + description: + "Reconcile the current user's OWN chats from S3 into the index " + + '(self-service, non-admin). Only sessions belonging to the caller are ' + + 'touched. A manual fallback when realtime indexing has not caught up.', + operationId: 'syncMyChats', + }) + @ApiOkResponse({ type: SyncChatsResponseDto }) + @Post('sync') + async syncMine( + @Body() dto: SyncChatsDto, + @Req() req: AuthedRequest, + ): Promise { + return this.sync.syncForExternalUser(this.ownExternalId(req), dto.agentId); + } + + /** + * The caller's OWN bridle identity — the exact value their chats are keyed + * under (ChatSession.externalUserId / `bridle:`). Mirrors the bridle + * client handler + HTTP resolveClientId: owners/admins collapse to the shared + * `admin` channel; everyone else is their JWT `sub`. Keep these in lockstep — + * if they diverge, "my history" silently shows nothing. + */ + private ownExternalId(req: AuthedRequest): string { const sub = req.user?.sub; if (!sub) throw new ForbiddenException('No authenticated user'); - return sub; + const roles = req.user?.roles ?? []; + const isAdmin = + roles.includes(UserRoleTypes.Owner) || + roles.includes(UserRoleTypes.Admin); + return isAdmin ? 'admin' : sub; } /** @@ -144,13 +172,13 @@ export class MyChatController { */ private async requireOwned( id: string, - sub: string, + externalUserId: string, ): Promise { const session = await this.chats.findById(id); if ( !session || session.channel !== OWN_CHANNEL || - session.externalUserId !== sub + session.externalUserId !== externalUserId ) { throw new NotFoundException(`Chat ${id} not found`); } diff --git a/app/slices/chat/components/chatList/Provider.vue b/app/slices/chat/components/chatList/Provider.vue index 342df22..5ddd834 100644 --- a/app/slices/chat/components/chatList/Provider.vue +++ b/app/slices/chat/components/chatList/Provider.vue @@ -1,20 +1,48 @@ diff --git a/app/slices/chat/stores/chat.ts b/app/slices/chat/stores/chat.ts index 26d715f..3b3da68 100644 --- a/app/slices/chat/stores/chat.ts +++ b/app/slices/chat/stores/chat.ts @@ -1,4 +1,7 @@ import { ChatsService } from '#api'; +import { client as apiClient } from '#api/data/repositories/api/client.gen'; + +export type ChatExportFormat = 'json' | 'markdown' | 'csv'; // The API wraps every response in { success, data }; unwrap to the payload. interface IEnvelope { @@ -12,6 +15,25 @@ function unwrap(body: unknown): T | null { return (body ?? null) as T | null; } +// LLM-derived structured insights (null until the cron-batch computes them). +export interface IChatInsights { + topics: string[]; + sentiment: 'positive' | 'neutral' | 'negative' | 'mixed'; + resolved: boolean; + language: string; +} + +// The current user's 👍/👎 on an assistant message. +export interface IChatFeedback { + id: string; + messageId: string; + rating: number; // 1 | -1 + comment: string | null; + source: string; + authorId: string | null; + createdAt: string; +} + // One of the current user's own chat sessions (index metadata). Server-scoped // to the caller's JWT — the app never passes a user id. export interface IChatSession { @@ -26,6 +48,7 @@ export interface IChatSession { messageCount: number; userMessageCount: number; summary: string | null; + insights: IChatInsights | null; archived: boolean; createdAt: string; updatedAt: string; @@ -108,5 +131,57 @@ export const useChatStore = defineStore('chat', () => { return unwrap(res.data); } - return { listMine, getMine, messages, syncMine }; + async function feedback(id: string): Promise { + const res = await ChatsService.listMyChatFeedback({ path: { id } }); + return unwrap(res.data) ?? []; + } + + async function rate( + id: string, + messageId: string, + rating: 1 | -1, + ): Promise { + await ChatsService.createMyChatFeedback({ + path: { id }, + body: { messageId, rating }, + }); + } + + async function unrate(id: string, messageId: string): Promise { + await ChatsService.deleteMyChatFeedback({ path: { id, messageId } }); + } + + // Export is a file download — use the raw axios client (carries the base URL + // + Bearer header from the shared client config) with a blob response, then + // trigger a browser download. + async function exportChat( + id: string, + format: ChatExportFormat, + ): Promise { + const res = await apiClient.instance.get(`/me/chats/${id}/export`, { + params: { format }, + responseType: 'blob', + }); + const dispo = (res.headers['content-disposition'] as string) ?? ''; + const filename = + dispo.match(/filename="?([^"]+)"?/)?.[1] ?? + `chat-${id}.${format === 'markdown' ? 'md' : format}`; + const url = URL.createObjectURL(res.data as Blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + + return { + listMine, + getMine, + messages, + syncMine, + feedback, + rate, + unrate, + exportChat, + }; }); diff --git a/app/slices/setup/api/data/repositories/api/sdk.gen.ts b/app/slices/setup/api/data/repositories/api/sdk.gen.ts index 3b38562..6630182 100644 --- a/app/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/app/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -140,6 +140,13 @@ import type { GetMyChatMessagesResponse, SyncMyChatsData, SyncMyChatsResponse, + ListMyChatFeedbackData, + ListMyChatFeedbackResponse, + CreateMyChatFeedbackData, + CreateMyChatFeedbackResponse, + DeleteMyChatFeedbackData, + DeleteMyChatFeedbackResponse, + ExportMyChatData, GetAgentChannelsData, GetAgentChannelsResponse, SetAgentChannelsData, @@ -2205,6 +2212,74 @@ export class ChatsService { }, }); } + + /** + * List the current user's own feedback for one of their chats. + */ + public static listMyChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + ListMyChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}/feedback", + ...options, + }); + } + + /** + * Set the current user's 👍/👎 on a message in their own chat. + */ + public static createMyChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + CreateMyChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}/feedback", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } + + /** + * Clear the current user's feedback on a message (toggle-off). + */ + public static deleteMyChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).delete< + DeleteMyChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}/feedback/{messageId}", + ...options, + }); + } + + /** + * Download the current user's own chat as json / markdown / csv. + */ + public static exportMyChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + unknown, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}/export", + ...options, + }); + } } export class UsersService { diff --git a/app/slices/setup/api/data/repositories/api/types.gen.ts b/app/slices/setup/api/data/repositories/api/types.gen.ts index f5cabab..e4d193e 100644 --- a/app/slices/setup/api/data/repositories/api/types.gen.ts +++ b/app/slices/setup/api/data/repositories/api/types.gen.ts @@ -2754,6 +2754,73 @@ export type SyncMyChatsResponses = { export type SyncMyChatsResponse = SyncMyChatsResponses[keyof SyncMyChatsResponses]; +export type ListMyChatFeedbackData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/me/chats/{id}/feedback"; +}; + +export type ListMyChatFeedbackResponses = { + 200: Array; +}; + +export type ListMyChatFeedbackResponse = + ListMyChatFeedbackResponses[keyof ListMyChatFeedbackResponses]; + +export type CreateMyChatFeedbackData = { + body: CreateChatFeedbackDto; + path: { + id: string; + }; + query?: never; + url: "/me/chats/{id}/feedback"; +}; + +export type CreateMyChatFeedbackResponses = { + 200: ChatFeedbackDto; +}; + +export type CreateMyChatFeedbackResponse = + CreateMyChatFeedbackResponses[keyof CreateMyChatFeedbackResponses]; + +export type DeleteMyChatFeedbackData = { + body?: never; + path: { + id: string; + messageId: string; + }; + query?: never; + url: "/me/chats/{id}/feedback/{messageId}"; +}; + +export type DeleteMyChatFeedbackResponses = { + 204: void; +}; + +export type DeleteMyChatFeedbackResponse = + DeleteMyChatFeedbackResponses[keyof DeleteMyChatFeedbackResponses]; + +export type ExportMyChatData = { + body?: never; + path: { + id: string; + }; + query?: { + /** + * Download format. json = raw messages, markdown/csv = transcript. + */ + format?: "json" | "markdown" | "csv"; + }; + url: "/me/chats/{id}/export"; +}; + +export type ExportMyChatResponses = { + 200: unknown; +}; + export type GetAgentChannelsData = { body?: never; path: { From 4b8c5059f5138b6d18edf9e0be0359f80379e1b7 Mon Sep 17 00:00:00 2001 From: maksym Date: Thu, 16 Jul 2026 17:46:38 +0200 Subject: [PATCH 18/21] =?UTF-8?q?feat(chat):=20match=20admin=20=E2=80=94?= =?UTF-8?q?=20insights=20meta=20beside=20header=20in=20app=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move sentiment/resolved/language up next to the 'Summary & insights' label (topics stay in their own row below), so the app detail view lays out the same way as the admin one. Co-Authored-By: Claude Opus 4.8 --- .../chat/components/chatDetail/Provider.vue | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/app/slices/chat/components/chatDetail/Provider.vue b/app/slices/chat/components/chatDetail/Provider.vue index 46e3b72..ebef61f 100644 --- a/app/slices/chat/components/chatDetail/Provider.vue +++ b/app/slices/chat/components/chatDetail/Provider.vue @@ -137,14 +137,36 @@ const sentimentClass = computed(() => { v-if="session.summary || session.insights" class="mt-3 rounded bg-muted/40 p-3" > - - Summary & insights - -

+ +

+ + Summary & insights + + +
+

{{ session.summary }}

+
{ > {{ topic }} - - {{ session.insights.sentiment }} - - - {{ session.insights.resolved ? 'resolved' : 'unresolved' }} - - - {{ session.insights.language }} -
From e53f18636a1b0cd392dcc0712934bf66f8f30ea1 Mon Sep 17 00:00:00 2001 From: maksym Date: Thu, 16 Jul 2026 17:57:10 +0200 Subject: [PATCH 19/21] feat(chat): add new chat feedback and sync functionalities Implement several new API endpoints for managing chat feedback and syncing user chats: - Add `syncMyChats` for reconciling the user's chats from S3. - Introduce endpoints for listing, creating, and deleting chat feedback. - Add an export functionality for downloading chat data in various formats. Enhance type definitions to support these new features and improve the user experience in the chat detail view with updated sentiment handling. --- .../api/data/repositories/api/sdk.gen.ts | 97 +++++++++++++++++++ .../api/data/repositories/api/types.gen.ts | 81 ++++++++++++++++ .../setup/theme/assets/css/tailwind.css | 7 ++ .../chat/components/chatDetail/Provider.vue | 87 ++++++++--------- .../setup/theme/assets/css/tailwind.css | 7 ++ .../setup/theme/components/badge/Badge.vue | 26 +++++ .../setup/theme/components/badge/index.ts | 24 +++++ 7 files changed, 281 insertions(+), 48 deletions(-) create mode 100644 app/slices/setup/theme/components/badge/Badge.vue create mode 100644 app/slices/setup/theme/components/badge/index.ts diff --git a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts index 21c76bf..6630182 100644 --- a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -138,6 +138,15 @@ import type { GetMyChatResponse, GetMyChatMessagesData, GetMyChatMessagesResponse, + SyncMyChatsData, + SyncMyChatsResponse, + ListMyChatFeedbackData, + ListMyChatFeedbackResponse, + CreateMyChatFeedbackData, + CreateMyChatFeedbackResponse, + DeleteMyChatFeedbackData, + DeleteMyChatFeedbackResponse, + ExportMyChatData, GetAgentChannelsData, GetAgentChannelsResponse, SetAgentChannelsData, @@ -2183,6 +2192,94 @@ export class ChatsService { ...options, }); } + + /** + * Reconcile the current user's OWN chats from S3 into the index (self-service, non-admin). Only sessions belonging to the caller are touched. A manual fallback when realtime indexing has not caught up. + */ + public static syncMyChats( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + SyncMyChatsResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats/sync", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } + + /** + * List the current user's own feedback for one of their chats. + */ + public static listMyChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + ListMyChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}/feedback", + ...options, + }); + } + + /** + * Set the current user's 👍/👎 on a message in their own chat. + */ + public static createMyChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + CreateMyChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}/feedback", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } + + /** + * Clear the current user's feedback on a message (toggle-off). + */ + public static deleteMyChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).delete< + DeleteMyChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}/feedback/{messageId}", + ...options, + }); + } + + /** + * Download the current user's own chat as json / markdown / csv. + */ + public static exportMyChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + unknown, + unknown, + ThrowOnError + >({ + url: "/me/chats/{id}/export", + ...options, + }); + } } export class UsersService { diff --git a/admin/slices/setup/api/data/repositories/api/types.gen.ts b/admin/slices/setup/api/data/repositories/api/types.gen.ts index f51f91d..e4d193e 100644 --- a/admin/slices/setup/api/data/repositories/api/types.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/types.gen.ts @@ -2740,6 +2740,87 @@ export type GetMyChatMessagesResponses = { export type GetMyChatMessagesResponse = GetMyChatMessagesResponses[keyof GetMyChatMessagesResponses]; +export type SyncMyChatsData = { + body: SyncChatsDto; + path?: never; + query?: never; + url: "/me/chats/sync"; +}; + +export type SyncMyChatsResponses = { + 200: SyncChatsResponseDto; +}; + +export type SyncMyChatsResponse = + SyncMyChatsResponses[keyof SyncMyChatsResponses]; + +export type ListMyChatFeedbackData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/me/chats/{id}/feedback"; +}; + +export type ListMyChatFeedbackResponses = { + 200: Array; +}; + +export type ListMyChatFeedbackResponse = + ListMyChatFeedbackResponses[keyof ListMyChatFeedbackResponses]; + +export type CreateMyChatFeedbackData = { + body: CreateChatFeedbackDto; + path: { + id: string; + }; + query?: never; + url: "/me/chats/{id}/feedback"; +}; + +export type CreateMyChatFeedbackResponses = { + 200: ChatFeedbackDto; +}; + +export type CreateMyChatFeedbackResponse = + CreateMyChatFeedbackResponses[keyof CreateMyChatFeedbackResponses]; + +export type DeleteMyChatFeedbackData = { + body?: never; + path: { + id: string; + messageId: string; + }; + query?: never; + url: "/me/chats/{id}/feedback/{messageId}"; +}; + +export type DeleteMyChatFeedbackResponses = { + 204: void; +}; + +export type DeleteMyChatFeedbackResponse = + DeleteMyChatFeedbackResponses[keyof DeleteMyChatFeedbackResponses]; + +export type ExportMyChatData = { + body?: never; + path: { + id: string; + }; + query?: { + /** + * Download format. json = raw messages, markdown/csv = transcript. + */ + format?: "json" | "markdown" | "csv"; + }; + url: "/me/chats/{id}/export"; +}; + +export type ExportMyChatResponses = { + 200: unknown; +}; + export type GetAgentChannelsData = { body?: never; path: { diff --git a/admin/slices/setup/theme/assets/css/tailwind.css b/admin/slices/setup/theme/assets/css/tailwind.css index a364b31..3f9dd39 100644 --- a/admin/slices/setup/theme/assets/css/tailwind.css +++ b/admin/slices/setup/theme/assets/css/tailwind.css @@ -3,6 +3,13 @@ @custom-variant dark (&:is(.dark *)); +@layer base { + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); diff --git a/app/slices/chat/components/chatDetail/Provider.vue b/app/slices/chat/components/chatDetail/Provider.vue index ebef61f..de03f6d 100644 --- a/app/slices/chat/components/chatDetail/Provider.vue +++ b/app/slices/chat/components/chatDetail/Provider.vue @@ -87,18 +87,12 @@ function fmt(iso?: string | null): string { return iso ? new Date(iso).toLocaleString() : '—'; } -const sentimentClass = computed(() => { - switch (session.value?.insights?.sentiment) { - case 'positive': - return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400'; - case 'negative': - return 'bg-rose-500/15 text-rose-700 dark:text-rose-400'; - case 'mixed': - return 'bg-amber-500/15 text-amber-700 dark:text-amber-400'; - default: - return 'bg-muted text-muted-foreground'; - } -}); +const sentimentVariant: Record = { + positive: 'default', + neutral: 'destructive', + negative: 'outline', + mixed: 'secondary', +};