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..7aa4c99 --- /dev/null +++ b/admin/slices/chat/components/chatDetail/Provider.vue @@ -0,0 +1,234 @@ + + + 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..6da9700 --- /dev/null +++ b/admin/slices/chat/components/chatMessage/Bubble.vue @@ -0,0 +1,137 @@ + + + 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..0bddc06 --- /dev/null +++ b/admin/slices/chat/stores/chat.ts @@ -0,0 +1,207 @@ +import { ChatsService } from '#api/data'; +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. +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; +} + +// hey-api doesn't throw on 4xx/5xx — it returns { error }. Surface it so the UI +// can show the API's message (e.g. a rejected LLM credential) instead of a +// silent no-op. +function throwIfError( + res: { error?: unknown; response?: { status?: number } }, + action: string, +): void { + if (res.error === undefined || res.error === null) return; + const msg = (res.error as { message?: string }).message; + throw new Error( + msg ? `${action}: ${msg}` : `${action} failed (HTTP ${res.response?.status ?? '?'})`, + ); +} + +export type ChatChannel = 'bridle' | 'telegram' | 'slack' | 'internal'; + +export interface IChatInsights { + topics: string[]; + sentiment: 'positive' | 'neutral' | 'negative' | 'mixed'; + resolved: boolean; + language: string; +} + +export interface IChatFeedback { + id: string; + messageId: string; + rating: number; // 1 | -1 + comment: string | null; + source: string; + authorId: string | null; + createdAt: string; +} + +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: IChatInsights | null; // null when not yet computed + 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); + } + + async function summarize(id: string): Promise { + const res = await ChatsService.summarizeChat({ path: { id } }); + throwIfError(res, 'Summarize'); + return unwrap(res.data); + } + + async function feedback(id: string): Promise { + const res = await ChatsService.getMyChatFeedback({ path: { id } }); + return unwrap(res.data) ?? []; + } + + async function rate(id: string, messageId: string, rating: 1 | -1): Promise { + await ChatsService.createChatFeedback({ path: { id }, body: { messageId, rating } }); + } + + async function unrate(id: string, messageId: string): Promise { + await ChatsService.deleteChatFeedback({ path: { id, messageId } }); + } + + // Export is a file download — use the raw axios client (adds the Bearer header + // via the interceptor) with a blob response, then trigger a browser download. + async function exportChat(id: string, format: ChatExportFormat): Promise { + const res = await apiClient.instance.get(`/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 { + list, + getById, + messages, + sync, + summarize, + feedback, + rate, + unrate, + exportChat, + }; +}); 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..ff51c74 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,255 @@ 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 CreateChatFeedbackDtoSchema = { + type: "object", + properties: { + messageId: { + type: "string", + description: "Event.id of the rated assistant message", + }, + rating: { + type: "number", + enum: [1, -1], + description: "1 = 👍, -1 = 👎", + }, + comment: { + type: "string", + }, + }, + required: ["messageId", "rating"], +} as const; + +export const ChatFeedbackDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + }, + messageId: { + type: "string", + }, + rating: { + type: "number", + enum: [1, -1], + }, + comment: { + type: "object", + nullable: true, + }, + source: { + type: "string", + example: "admin", + }, + authorId: { + type: "object", + nullable: true, + }, + createdAt: { + format: "date-time", + type: "string", + }, + }, + required: ["id", "messageId", "rating", "source", "createdAt"], +} as const; + export const TelegramChannelConfigDtoSchema = { 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..6630182 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,38 @@ import type { SkillControllerFindByIdData, SkillControllerUpdateData, FindDependentAgentsData, + GetChatsData, + GetChatsResponse, + GetChatData, + GetChatResponse, + GetChatMessagesData, + GetChatMessagesResponse, + SyncChatsData, + SyncChatsResponse, + SummarizeChatData, + SummarizeChatResponse, + GetMyChatFeedbackData, + GetMyChatFeedbackResponse, + CreateChatFeedbackData, + CreateChatFeedbackResponse, + DeleteChatFeedbackData, + DeleteChatFeedbackResponse, + ExportChatData, + GetMyChatsData, + GetMyChatsResponse, + GetMyChatData, + GetMyChatResponse, + GetMyChatMessagesData, + GetMyChatMessagesResponse, + SyncMyChatsData, + SyncMyChatsResponse, + ListMyChatFeedbackData, + ListMyChatFeedbackResponse, + CreateMyChatFeedbackData, + CreateMyChatFeedbackResponse, + DeleteMyChatFeedbackData, + DeleteMyChatFeedbackResponse, + ExportMyChatData, GetAgentChannelsData, GetAgentChannelsResponse, SetAgentChannelsData, @@ -1960,6 +1992,296 @@ 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, + }); + } + + /** + * List the current user’s feedback for a chat session. + */ + public static getMyChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetMyChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/feedback", + ...options, + }); + } + + /** + * Set the current user’s 👍/👎 on an assistant message. + */ + public static createChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + CreateChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/feedback", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } + + /** + * Clear the current user’s feedback on a message (toggle-off). + */ + public static deleteChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).delete< + DeleteChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/feedback/{messageId}", + ...options, + }); + } + + /** + * Download a chat transcript as json / markdown / csv. + */ + public static exportChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + unknown, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/export", + ...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, + }); + } + + /** + * 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 { /** * List all users 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..e4d193e 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,122 @@ 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 CreateChatFeedbackDto = { + /** + * Event.id of the rated assistant message + */ + messageId: string; + /** + * 1 = 👍, -1 = 👎 + */ + rating: 1 | -1; + comment?: string; +}; + +export type ChatFeedbackDto = { + id: string; + messageId: string; + rating: 1 | -1; + comment?: { + [key: string]: unknown; + } | null; + source: string; + authorId?: { + [key: string]: unknown; + } | null; + createdAt: string; +}; + export type TelegramChannelConfigDto = { /** * Telegram bot HTTP API token (issued by @BotFather). @@ -2393,6 +2509,318 @@ 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 GetMyChatFeedbackData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}/feedback"; +}; + +export type GetMyChatFeedbackResponses = { + 200: Array; +}; + +export type GetMyChatFeedbackResponse = + GetMyChatFeedbackResponses[keyof GetMyChatFeedbackResponses]; + +export type CreateChatFeedbackData = { + body: CreateChatFeedbackDto; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}/feedback"; +}; + +export type CreateChatFeedbackResponses = { + 200: ChatFeedbackDto; +}; + +export type CreateChatFeedbackResponse = + CreateChatFeedbackResponses[keyof CreateChatFeedbackResponses]; + +export type DeleteChatFeedbackData = { + body?: never; + path: { + id: string; + messageId: string; + }; + query?: never; + url: "/chats/{id}/feedback/{messageId}"; +}; + +export type DeleteChatFeedbackResponses = { + 204: void; +}; + +export type DeleteChatFeedbackResponse = + DeleteChatFeedbackResponses[keyof DeleteChatFeedbackResponses]; + +export type ExportChatData = { + body?: never; + path: { + id: string; + }; + query?: { + /** + * Download format. json = raw messages, markdown/csv = transcript. + */ + format?: "json" | "markdown" | "csv"; + }; + url: "/chats/{id}/export"; +}; + +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 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/api/.env.example b/api/.env.example index 14380d4..9bb1806 100644 --- a/api/.env.example +++ b/api/.env.example @@ -12,6 +12,12 @@ JWT_EXPIRES_IN=7d # Workflow runtime: 'argo' (real Argo at ARGO_WORKFLOWS_URL) or 'mock' (in-memory stub) WORKFLOW_PROVIDER=argo +# Chat insight cron-batch: every N seconds, LLM-summarize chats that are +# unchanged for 1h+ and have 3+ user messages (see ChatInsightService gate). +# Unset or <= 0 disables the batch — on-demand summarize (POST /chats/:id/summarize) +# still works either way. +CHAT_INSIGHT_INTERVAL_SEC=900 + BRIDLE_API_KEY=dev-bridle-api-key-change-me # Browser-pool — keep TOKEN in sync with docker-compose.yml's browserless service. 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..464fa1e --- /dev/null +++ b/api/src/slices/agent/file/domain/transcriptReader.service.spec.ts @@ -0,0 +1,148 @@ +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..7ffbd84 --- /dev/null +++ b/api/src/slices/agent/file/domain/transcriptReader.service.ts @@ -0,0 +1,225 @@ +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..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, @@ -29,7 +30,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') @@ -38,10 +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', @@ -55,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 }; @@ -75,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) => { @@ -161,9 +193,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,90 +209,27 @@ 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 }; + if (status !== 404) { + this.logger.warn( + `Transcript read failed for ${agentId}/${channel}: ${(err as Error).message}`, + ); } - 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; - + const { messages, nextCursor, hasMore } = TranscriptReaderService.page( + all, + query.cursor, + limit, + ); return { - messages: page, + messages: messages as TranscriptMessageDto[], channel, - nextCursor: hasMore ? String(startIdx) : null, + nextCursor, 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 (!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. - } - } - 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); - } - @ApiOperation({ description: "Delete the persisted chat transcript for an agent/channel. Used to start a fresh chat — UI clears, refresh shows empty. Note: the agent runtime's in-memory session may still hold context until the next pod restart.", 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.controller.ts b/api/src/slices/chat/chat.controller.ts new file mode 100644 index 0000000..688a170 --- /dev/null +++ b/api/src/slices/chat/chat.controller.ts @@ -0,0 +1,277 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Query, + Body, + Req, + Res, + HttpCode, + ForbiddenException, + NotFoundException, + UseGuards, + Logger, +} from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiOkResponse, +} from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { JwtAuthGuard, Roles, RolesGuard } from '#/user/auth/guards'; +import { UserRoleTypes } from '#/user/user/domain'; +import { IAuthTokenPayload } from '#/user/auth/domain/auth.types'; +import { + TranscriptReaderService, + TranscriptMessage, +} from '#/agent/file/domain'; +import { + IChatGateway, + ChatSyncService, + ChatInsightService, + formatChatExport, +} from './domain'; +import { + FilterChatsDto, + ChatListResponseDto, + ChatSessionDto, + ChatMessagesQueryDto, + ChatMessagesResponseDto, + SyncChatsDto, + SyncChatsResponseDto, + CreateChatFeedbackDto, + ChatFeedbackDto, + ExportChatQueryDto, +} from './dtos'; + +type AuthedRequest = Request & { user?: IAuthTokenPayload }; +const FEEDBACK_SOURCE = 'admin'; + +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, + private readonly insight: ChatInsightService, + ) {} + + @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); + } + + @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); + } + + @ApiOperation({ + description: 'Set the current user’s 👍/👎 on an assistant message.', + operationId: 'createChatFeedback', + }) + @ApiOkResponse({ type: ChatFeedbackDto }) + @Post(':id/feedback') + async createFeedback( + @Param('id') id: string, + @Body() dto: CreateChatFeedbackDto, + @Req() req: AuthedRequest, + ): Promise { + const authorId = req.user?.sub; + if (!authorId) throw new ForbiddenException('No authenticated user'); + const session = await this.chats.findById(id); + if (!session) throw new NotFoundException(`Chat ${id} not found`); + return this.chats.upsertFeedback({ + sessionId: id, + messageId: dto.messageId, + rating: dto.rating, + comment: dto.comment, + source: FEEDBACK_SOURCE, + authorId, + }); + } + + @ApiOperation({ + description: 'Clear the current user’s feedback on a message (toggle-off).', + operationId: 'deleteChatFeedback', + }) + @HttpCode(204) + @Delete(':id/feedback/:messageId') + async deleteFeedback( + @Param('id') id: string, + @Param('messageId') messageId: string, + @Req() req: AuthedRequest, + ): Promise { + const authorId = req.user?.sub; + if (!authorId) throw new ForbiddenException('No authenticated user'); + await this.chats.deleteFeedback(id, messageId, authorId); + } + + @ApiOperation({ + description: 'List the current user’s feedback for a chat session.', + operationId: 'getMyChatFeedback', + }) + @ApiOkResponse({ type: [ChatFeedbackDto] }) + @Get(':id/feedback') + async myFeedback( + @Param('id') id: string, + @Req() req: AuthedRequest, + ): Promise { + const authorId = req.user?.sub ?? ''; + return this.chats.listFeedbackByAuthor(id, authorId); + } + + @ApiOperation({ + description: 'Download a chat transcript as json / markdown / csv.', + operationId: 'exportChat', + }) + // @Res() bypasses the global {success,data} interceptor so we return the raw + // file with download headers (same pattern as file export). + @Get(':id/export') + async export( + @Param('id') id: string, + @Query() q: ExportChatQueryDto, + @Res() res: Response, + ): Promise { + const session = await this.chats.findById(id); + if (!session) throw new NotFoundException(`Chat ${id} not found`); + + const format = q.format ?? 'json'; + const types: TranscriptMessage['role'][] = + format === 'json' ? ALLOWED_TYPES : ['user', 'assistant', 'summary']; + const path = `data/sessions/${session.sessionKey}.jsonl`; + let messages: TranscriptMessage[] = []; + try { + messages = await this.reader.read(session.agentId, path, { + types, + filterTransient: true, + }); + } catch (err) { + this.logger.warn( + `export read failed for ${session.agentId}/${session.sessionKey}: ${(err as Error).message}`, + ); + } + + const { body, contentType, ext } = formatChatExport( + format, + session, + messages, + ); + const safeKey = session.sessionKey.replace(/[^\w.-]/g, '_'); + res.setHeader('Content-Type', contentType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="chat-${safeKey}.${ext}"`, + ); + res.end(body); + } +} diff --git a/api/src/slices/chat/chat.module.ts b/api/src/slices/chat/chat.module.ts new file mode 100644 index 0000000..8578fe0 --- /dev/null +++ b/api/src/slices/chat/chat.module.ts @@ -0,0 +1,31 @@ +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 { MyChatController } from './myChat.controller'; +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), + LlmModule, + ], + controllers: [ChatController, MyChatController], + providers: [ + ChatMapper, + ChatSyncService, + ChatInsightService, + { + 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..3ba1bf3 --- /dev/null +++ b/api/src/slices/chat/data/chat.gateway.spec.ts @@ -0,0 +1,393 @@ +import { ChatGateway } from './chat.gateway'; +import { ChatMapper } from './chat.mapper'; +import { IChatActivity, 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 }) => { + // 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, + 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]; + }, + ), + 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) + .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 feedback: Record> = {}; + const fbMatch = (r: Record, w: Record) => + r.sessionId === w.sessionId && + (w.messageId === undefined || r.messageId === w.messageId) && + r.authorId === w.authorId; + + const chatFeedback = { + upsert: jest.fn( + async ({ + where, + create, + update, + }: { + where: { sessionId_messageId_authorId: Record }; + create: Record; + update: Record; + }) => { + const key = where.sessionId_messageId_authorId; + const existing = Object.values(feedback).find( + (r) => + r.sessionId === key.sessionId && + r.messageId === key.messageId && + r.authorId === key.authorId, + ); + if (existing) return Object.assign(existing, update); + feedback[create.id as string] = { + comment: null, + createdAt: new Date(0), + ...create, + }; + return feedback[create.id as string]; + }, + ), + deleteMany: jest.fn(async ({ where }: { where: Record }) => { + let count = 0; + for (const [id, r] of Object.entries(feedback)) { + if (fbMatch(r, where)) { + delete feedback[id]; + count++; + } + } + return { count }; + }), + findMany: jest.fn(async ({ where }: { where: Record }) => + Object.values(feedback).filter((r) => fbMatch(r, where)), + ), + }; + + const prisma = { + chatSession, + chatFeedback, + $transaction: jest.fn(async (ops: Promise[]) => Promise.all(ops)), + }; + return { prisma, rows, feedback }; +} + +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); + }); +}); + +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(); + 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); + }); +}); + +describe('ChatGateway feedback', () => { + const fb = (over: Record = {}) => ({ + sessionId: 'c1', + messageId: 'm1', + rating: 1, + source: 'admin', + authorId: 'u1', + ...over, + }); + + it('upserts then lists an author’s feedback', async () => { + const { gw } = newGateway(); + await gw.upsertFeedback(fb({ rating: 1 })); + const list = await gw.listFeedbackByAuthor('c1', 'u1'); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ messageId: 'm1', rating: 1 }); + }); + + it('re-rating the same message updates in place (no duplicate row)', async () => { + const { gw } = newGateway(); + await gw.upsertFeedback(fb({ rating: 1 })); + await gw.upsertFeedback(fb({ rating: -1 })); + const list = await gw.listFeedbackByAuthor('c1', 'u1'); + expect(list).toHaveLength(1); + expect(list[0].rating).toBe(-1); + }); + + it('deleteFeedback clears it (toggle-off)', async () => { + const { gw } = newGateway(); + await gw.upsertFeedback(fb()); + await gw.deleteFeedback('c1', 'm1', 'u1'); + expect(await gw.listFeedbackByAuthor('c1', 'u1')).toHaveLength(0); + }); + + it('scopes listing to the requesting author', async () => { + const { gw } = newGateway(); + await gw.upsertFeedback(fb({ authorId: 'u1' })); + await gw.upsertFeedback(fb({ authorId: 'u2', messageId: 'm2' })); + expect(await gw.listFeedbackByAuthor('c1', 'u1')).toHaveLength(1); + expect(await gw.listFeedbackByAuthor('c1', 'u2')).toHaveLength(1); + }); +}); 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..4a5aedd --- /dev/null +++ b/api/src/slices/chat/data/chat.gateway.ts @@ -0,0 +1,232 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, ChatFeedback } from '@prisma/client'; +import { PrismaService } from '#/setup/prisma/prisma.service'; +import { + IChatGateway, + IChatActivity, + IChatFeedbackData, + IChatFilter, + IChatInsightGate, + IChatInsights, + IChatListResult, + IChatReconcileInput, + IChatSessionData, + IUpsertChatFeedback, +} 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); + } + + 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; + } + + 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(), + }, + }); + } + + async upsertFeedback(i: IUpsertChatFeedback): Promise { + const record = await this.prisma.chatFeedback.upsert({ + where: { + sessionId_messageId_authorId: { + sessionId: i.sessionId, + messageId: i.messageId, + authorId: i.authorId, + }, + }, + create: { + id: `feedback-${crypto.randomUUID()}`, + sessionId: i.sessionId, + messageId: i.messageId, + authorId: i.authorId, + rating: i.rating, + comment: i.comment ?? null, + source: i.source, + }, + update: { rating: i.rating, comment: i.comment ?? null }, + }); + return this.toFeedback(record); + } + + async deleteFeedback( + sessionId: string, + messageId: string, + authorId: string, + ): Promise { + await this.prisma.chatFeedback.deleteMany({ + where: { sessionId, messageId, authorId }, + }); + } + + async listFeedbackByAuthor( + sessionId: string, + authorId: string, + ): Promise { + const records = await this.prisma.chatFeedback.findMany({ + where: { sessionId, authorId }, + orderBy: { createdAt: 'desc' }, + }); + return records.map((r) => this.toFeedback(r)); + } + + private toFeedback(r: ChatFeedback): IChatFeedbackData { + return { + id: r.id, + sessionId: r.sessionId, + messageId: r.messageId, + rating: r.rating, + comment: r.comment, + source: r.source, + authorId: r.authorId, + createdAt: r.createdAt, + }; + } + + private buildWhere(filter: IChatFilter): Prisma.ChatSessionWhereInput { + const where: Prisma.ChatSessionWhereInput = {}; + if (filter.agentId) where.agentId = filter.agentId; + if (filter.externalUserId) where.externalUserId = filter.externalUserId; + + 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..6cfcc21 --- /dev/null +++ b/api/src/slices/chat/data/chat.mapper.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { ChatSession } from '@prisma/client'; +import { + IChatActivity, + 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, + }; + } + + // 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) { + 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..e2b096b --- /dev/null +++ b/api/src/slices/chat/domain/chat.gateway.ts @@ -0,0 +1,75 @@ +import { + IChatActivity, + IChatFeedbackData, + IChatFilter, + IChatInsightGate, + IChatInsights, + IChatListResult, + IChatReconcileInput, + IChatSessionData, + IUpsertChatFeedback, +} 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; + + /** + * 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; + + /** + * 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; + + /** Upsert one author's 👍/👎 on a message (unique per session+message+author). */ + abstract upsertFeedback( + input: IUpsertChatFeedback, + ): Promise; + + /** Remove an author's feedback on a message (toggle-off / clear). */ + abstract deleteFeedback( + sessionId: string, + messageId: string, + authorId: string, + ): Promise; + + /** One author's feedback across a session (for rendering their ratings). */ + abstract listFeedbackByAuthor( + sessionId: string, + authorId: string, + ): 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..d0ca201 --- /dev/null +++ b/api/src/slices/chat/domain/chat.types.ts @@ -0,0 +1,135 @@ +// 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; // Prisma Json? — null when not yet computed + archived: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface IChatFilter { + agentId?: string; + channel?: string; + externalUserId?: string; // scope to one end user (App "my history") + 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 +} + +/** + * 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; +} + +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; +} + +/** A 👍/👎 on one assistant message. `source` = admin | app | telegram. */ +export interface IChatFeedbackData { + id: string; + sessionId: string; + messageId: string; // Event.id of the rated assistant message + rating: number; // 1 | -1 + comment: string | null; + source: string; + authorId: string | null; + createdAt: Date; +} + +export interface IUpsertChatFeedback { + sessionId: string; + messageId: string; + rating: number; // 1 | -1 + comment?: string | null; + source: string; + authorId: string; +} diff --git a/api/src/slices/chat/domain/chatExport.spec.ts b/api/src/slices/chat/domain/chatExport.spec.ts new file mode 100644 index 0000000..0c3559f --- /dev/null +++ b/api/src/slices/chat/domain/chatExport.spec.ts @@ -0,0 +1,65 @@ +import { formatChatExport } from './chatExport'; +import { IChatSessionData } from './chat.types'; +import type { TranscriptMessage } from '#/agent/file/domain'; + +const SESSION: IChatSessionData = { + id: 'c1', + agentId: 'a1', + channel: 'bridle', + externalUserId: 'admin', + sessionKey: 'bridle:admin', + title: null, + preview: null, + lastRole: 'assistant', + lastMessageAt: new Date('2026-07-16T10:00:00.000Z'), + messageCount: 2, + userMessageCount: 1, + lastIndexedEventId: null, + lastIndexedSize: 0, + summary: 'User asked about billing.', + summaryAt: new Date(0), + insights: null, + archived: false, + createdAt: new Date(0), + updatedAt: new Date(0), +}; + +const MESSAGES: TranscriptMessage[] = [ + { id: 'm1', role: 'user', text: 'hello, "quoted"', ts: 1000 }, + { id: 'm2', role: 'assistant', text: 'hi there', ts: 2000 }, +]; + +describe('formatChatExport', () => { + it('json → parseable object with chat + messages', () => { + const out = formatChatExport('json', SESSION, MESSAGES); + expect(out.ext).toBe('json'); + expect(out.contentType).toContain('application/json'); + const parsed = JSON.parse(out.body) as { + chat: { id: string; summary: string }; + messages: TranscriptMessage[]; + }; + expect(parsed.chat.id).toBe('c1'); + expect(parsed.chat.summary).toBe('User asked about billing.'); + expect(parsed.messages).toHaveLength(2); + }); + + it('markdown → header, summary, and transcript', () => { + const out = formatChatExport('markdown', SESSION, MESSAGES); + expect(out.ext).toBe('md'); + expect(out.body).toContain('# Chat — admin (bridle)'); + expect(out.body).toContain('## Summary'); + expect(out.body).toContain('User asked about billing.'); + expect(out.body).toContain('**User**'); + expect(out.body).toContain('**Assistant**'); + expect(out.body).toContain('hi there'); + }); + + it('csv → header + CSV-escaped rows (quotes doubled)', () => { + const out = formatChatExport('csv', SESSION, MESSAGES); + expect(out.ext).toBe('csv'); + const lines = out.body.trim().split('\n'); + expect(lines[0]).toBe('id,role,ts,datetime,text'); + expect(lines[1]).toContain('"hello, ""quoted"""'); // embedded quote escaped + expect(lines[1]).toContain('"user"'); + }); +}); diff --git a/api/src/slices/chat/domain/chatExport.ts b/api/src/slices/chat/domain/chatExport.ts new file mode 100644 index 0000000..4276e5a --- /dev/null +++ b/api/src/slices/chat/domain/chatExport.ts @@ -0,0 +1,109 @@ +import type { TranscriptMessage } from '#/agent/file/domain'; +import { IChatSessionData } from './chat.types'; + +export type ChatExportFormat = 'json' | 'markdown' | 'csv'; + +export interface ExportedChat { + body: string; + contentType: string; + ext: string; +} + +/** Render a chat transcript for download. Pure — the controller streams it. */ +export function formatChatExport( + format: ChatExportFormat, + session: IChatSessionData, + messages: TranscriptMessage[], +): ExportedChat { + switch (format) { + case 'markdown': + return { + body: toMarkdown(session, messages), + contentType: 'text/markdown; charset=utf-8', + ext: 'md', + }; + case 'csv': + return { + body: toCsv(messages), + contentType: 'text/csv; charset=utf-8', + ext: 'csv', + }; + default: + return { + body: toJson(session, messages), + contentType: 'application/json; charset=utf-8', + ext: 'json', + }; + } +} + +function who(s: IChatSessionData): string { + return s.title || s.externalUserId || '—'; +} + +function iso(ts: number): string { + return new Date(ts).toISOString(); +} + +function toJson(s: IChatSessionData, messages: TranscriptMessage[]): string { + return JSON.stringify( + { + chat: { + id: s.id, + agentId: s.agentId, + channel: s.channel, + externalUserId: s.externalUserId, + sessionKey: s.sessionKey, + messageCount: s.messageCount, + userMessageCount: s.userMessageCount, + lastMessageAt: s.lastMessageAt, + summary: s.summary, + insights: s.insights, + }, + messages, + }, + null, + 2, + ); +} + +function toMarkdown( + s: IChatSessionData, + messages: TranscriptMessage[], +): string { + const head = [ + `# Chat — ${who(s)} (${s.channel})`, + '', + `- Session: \`${s.sessionKey}\``, + `- Messages: ${s.messageCount} (${s.userMessageCount} from user)`, + `- Last activity: ${s.lastMessageAt.toISOString()}`, + ]; + if (s.summary) head.push('', '## Summary', '', s.summary); + head.push('', '## Transcript', ''); + + const body = messages.map((m) => { + const role = m.role.charAt(0).toUpperCase() + m.role.slice(1); + return `**${role}** · ${iso(m.ts)}\n\n${m.text}\n`; + }); + return head.join('\n') + '\n' + body.join('\n---\n\n') + '\n'; +} + +function csvCell(v: string): string { + return `"${v.replace(/"/g, '""')}"`; +} + +function toCsv(messages: TranscriptMessage[]): string { + const rows = ['id,role,ts,datetime,text']; + for (const m of messages) { + rows.push( + [ + csvCell(m.id), + csvCell(m.role), + String(m.ts), + csvCell(iso(m.ts)), + csvCell(m.text), + ].join(','), + ); + } + return rows.join('\n') + '\n'; +} 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..4659221 --- /dev/null +++ b/api/src/slices/chat/domain/chatInsight.service.spec.ts @@ -0,0 +1,186 @@ +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(); + }); + + it('aborts after the first rejected credential (does not hammer every session)', async () => { + global.fetch = jest.fn(async () => ({ + ok: false, + status: 401, + json: async () => ({}), + text: async () => 'invalid x-api-key', + })) as unknown as typeof fetch; + const { chats, saveInsights } = makeChats({ + eligible: [SESSION, { ...SESSION, id: 'c2' }, { ...SESSION, id: 'c3' }], + }); + const svc = new ChatInsightService(chats, readerStub(), llmsStub()); + const r = await svc.runBatch(); + expect(r.summarized).toBe(0); + expect(r.failed).toBe(1); // stopped after the first, not 3 + expect(saveInsights).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..86924b9 --- /dev/null +++ b/api/src/slices/chat/domain/chatInsight.service.ts @@ -0,0 +1,275 @@ +import { + BadGatewayException, + BadRequestException, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ILlmGateway, anthropicAuthHeaders } 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++; + // A rejected credential (401/403 → BadRequestException) dooms every + // session — abort the batch instead of hammering the LLM with a bad key. + if (err instanceof BadRequestException) { + this.logger.error( + `insight batch aborted — ${(err as Error).message}`, + ); + break; + } + 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', + '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, + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }], + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + if (res.status === 401 || res.status === 403) { + throw new BadRequestException( + `Anthropic rejected the API key (${res.status}) — update the Anthropic credential in Settings → LLM credentials.`, + ); + } + throw new BadGatewayException( + `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/chatSync.service.spec.ts b/api/src/slices/chat/domain/chatSync.service.spec.ts new file mode 100644 index 0000000..f8ecd6d --- /dev/null +++ b/api/src/slices/chat/domain/chatSync.service.spec.ts @@ -0,0 +1,125 @@ +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..73d2a78 --- /dev/null +++ b/api/src/slices/chat/domain/chatSync.service.ts @@ -0,0 +1,230 @@ +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; + } + + /** + * 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 { + 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) { + 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++; + 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..6b65c82 --- /dev/null +++ b/api/src/slices/chat/domain/index.ts @@ -0,0 +1,5 @@ +export * from './chat.types'; +export * from './chat.gateway'; +export * from './chatSync.service'; +export * from './chatInsight.service'; +export * from './chatExport'; diff --git a/api/src/slices/chat/dtos/chatFeedback.dto.ts b/api/src/slices/chat/dtos/chatFeedback.dto.ts new file mode 100644 index 0000000..b9367d4 --- /dev/null +++ b/api/src/slices/chat/dtos/chatFeedback.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsInt, IsOptional, IsString } from 'class-validator'; + +export class CreateChatFeedbackDto { + @ApiProperty({ description: 'Event.id of the rated assistant message' }) + @IsString() + messageId: string; + + @ApiProperty({ enum: [1, -1], description: '1 = 👍, -1 = 👎' }) + @IsInt() + @IsIn([1, -1]) + rating: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + comment?: string; +} + +export class ChatFeedbackDto { + @ApiProperty() id: string; + @ApiProperty() messageId: string; + @ApiProperty({ enum: [1, -1] }) rating: number; + @ApiPropertyOptional({ nullable: true }) comment: string | null; + @ApiProperty({ example: 'admin' }) source: string; + @ApiPropertyOptional({ nullable: true }) authorId: string | null; + @ApiProperty() createdAt: Date; +} 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..00d3087 --- /dev/null +++ b/api/src/slices/chat/dtos/chatMessage.dto.ts @@ -0,0 +1,62 @@ +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..ddeb3b7 --- /dev/null +++ b/api/src/slices/chat/dtos/chatSession.dto.ts @@ -0,0 +1,63 @@ +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; + + @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/exportChat.dto.ts b/api/src/slices/chat/dtos/exportChat.dto.ts new file mode 100644 index 0000000..1d3cb84 --- /dev/null +++ b/api/src/slices/chat/dtos/exportChat.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; +import type { ChatExportFormat } from '../domain'; + +export class ExportChatQueryDto { + @ApiPropertyOptional({ + enum: ['json', 'markdown', 'csv'], + default: 'json', + description: + 'Download format. json = raw messages, markdown/csv = transcript.', + }) + @IsOptional() + @IsIn(['json', 'markdown', 'csv']) + format?: ChatExportFormat; +} 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..4ab13be --- /dev/null +++ b/api/src/slices/chat/dtos/filterChats.dto.ts @@ -0,0 +1,65 @@ +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..94ad830 --- /dev/null +++ b/api/src/slices/chat/dtos/index.ts @@ -0,0 +1,7 @@ +export * from './chatSession.dto'; +export * from './filterChats.dto'; +export * from './chatMessage.dto'; +export * from './syncChats.dto'; +export * from './chatFeedback.dto'; +export * from './exportChat.dto'; +export * from './myChats.dto'; diff --git a/api/src/slices/chat/dtos/myChats.dto.ts b/api/src/slices/chat/dtos/myChats.dto.ts new file mode 100644 index 0000000..c4a1593 --- /dev/null +++ b/api/src/slices/chat/dtos/myChats.dto.ts @@ -0,0 +1,37 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsOptional, Max, Min } from 'class-validator'; + +const toBool = ({ value }: { value: unknown }) => + value === true || value === 'true'; + +/** + * Query for the end-user "my history" list. Deliberately narrow: no agentId / + * channel / search — the current user only ever sees their own bridle chats + * (scoped server-side by JWT `sub`), so those admin filters must not be exposed. + */ +export class MyChatsQueryDto { + @ApiPropertyOptional({ + default: false, + description: 'Include archived chats', + }) + @IsOptional() + @Transform(toBool) + @IsBoolean() + archived?: 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/syncChats.dto.ts b/api/src/slices/chat/dtos/syncChats.dto.ts new file mode 100644 index 0000000..a68011d --- /dev/null +++ b/api/src/slices/chat/dtos/syncChats.dto.ts @@ -0,0 +1,18 @@ +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; +} diff --git a/api/src/slices/chat/myChat.controller.spec.ts b/api/src/slices/chat/myChat.controller.spec.ts new file mode 100644 index 0000000..55c3a1d --- /dev/null +++ b/api/src/slices/chat/myChat.controller.spec.ts @@ -0,0 +1,249 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Request } from 'express'; +import { MyChatController } from './myChat.controller'; +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'; + +const OWNED: IChatSessionData = { + id: 'c1', + agentId: 'a1', + channel: 'bridle', + externalUserId: SUB, // owned by SUB + sessionKey: `bridle:${SUB}`, + title: 'My chat', + preview: 'hi', + lastRole: 'assistant', + lastMessageAt: new Date(2000), + messageCount: 4, + userMessageCount: 2, + lastIndexedEventId: null, + lastIndexedSize: 100, + summary: null, + summaryAt: null, + insights: null, + archived: false, + createdAt: new Date(0), + updatedAt: new Date(0), +}; + +function makeChats(findById: jest.Mock) { + const list = jest.fn(async () => ({ items: [OWNED], total: 1 })); + const upsertFeedback = jest.fn(async (i: Record) => ({ + id: 'f1', + createdAt: new Date(0), + comment: null, + ...i, + })); + const deleteFeedback = jest.fn(async () => {}); + const listFeedbackByAuthor = jest.fn(async () => []); + const chats = { + findById, + list, + upsertFeedback, + deleteFeedback, + listFeedbackByAuthor, + } as unknown as IChatGateway; + return { chats, findById, list, upsertFeedback, deleteFeedback }; +} + +function readerStub(read?: jest.Mock) { + return { + read: + 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 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, upsertFeedback, deleteFeedback } = makeChats(findById); + const { sync, syncForExternalUser } = syncStub(); + const ctrl = new MyChatController(chats, readerStub(read), sync); + return { ctrl, list, syncForExternalUser, upsertFeedback, deleteFeedback }; +} + +describe('MyChatController.list', () => { + 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', + externalUserId: SUB, + archived: false, + page: 2, + perPage: 10, + }); + 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 { ctrl } = makeController(jest.fn()); + await expect(ctrl.list({}, req(undefined))).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); +}); + +describe('MyChatController.detail', () => { + 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 { ctrl } = makeController(jest.fn(async () => OWNED)); + await expect(ctrl.detail('c1', req('someone-else'))).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('404s a non-bridle session even with a matching externalUserId', async () => { + const telegram = { ...OWNED, channel: 'telegram' }; + const { ctrl } = makeController(jest.fn(async () => telegram)); + await expect(ctrl.detail('c1', req(SUB))).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('404s a missing chat', async () => { + const { ctrl } = makeController(jest.fn(async () => null)); + await expect(ctrl.detail('nope', req(SUB))).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); + +describe('MyChatController.messages', () => { + it('reads the transcript for an owned chat', async () => { + const read = jest.fn(async () => [ + { id: 'm1', role: 'user', text: 'hi', ts: 1 }, + ]); + const { ctrl } = makeController( + jest.fn(async () => OWNED), + read, + ); + const res = await ctrl.messages('c1', {}, req(SUB)); + expect(read).toHaveBeenCalledWith( + 'a1', + 'data/sessions/bridle:user-1.jsonl', + { types: ['user', 'assistant', 'summary'], filterTransient: true }, + ); + expect(res.messages).toHaveLength(1); + }); + + it("does not read another user's transcript", async () => { + const read = jest.fn(async () => []); + const { ctrl } = makeController( + jest.fn(async () => OWNED), + read, + ); + await expect( + ctrl.messages('c1', {}, req('someone-else')), + ).rejects.toBeInstanceOf(NotFoundException); + expect(read).not.toHaveBeenCalled(); + }); + + it('returns an empty page when the file is unreadable', async () => { + const read = jest.fn(async () => { + throw new Error('gone'); + }); + 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 feedback', () => { + it("records feedback with source='app' and the caller's real sub as author", async () => { + // Owner: ownership resolves to 'admin', but the feedback author must stay + // the real user id (sub), not the shared channel. + const adminChat = { ...OWNED, externalUserId: 'admin' }; + const { ctrl, upsertFeedback } = makeController( + jest.fn(async () => adminChat), + ); + await ctrl.createFeedback( + 'c1', + { messageId: 'm2', rating: 1 }, + req(SUB, [UserRoleTypes.Owner]), + ); + expect(upsertFeedback).toHaveBeenCalledWith({ + sessionId: 'c1', + messageId: 'm2', + rating: 1, + comment: undefined, + source: 'app', + authorId: SUB, + }); + }); + + it("won't record feedback on someone else's chat", async () => { + const { ctrl, upsertFeedback } = makeController(jest.fn(async () => OWNED)); + await expect( + ctrl.createFeedback('c1', { messageId: 'm2', rating: 1 }, req('other')), + ).rejects.toBeInstanceOf(NotFoundException); + expect(upsertFeedback).not.toHaveBeenCalled(); + }); + + it('clears feedback keyed by the real sub', async () => { + const { ctrl, deleteFeedback } = makeController(jest.fn(async () => OWNED)); + await ctrl.deleteFeedback('c1', 'm2', req(SUB)); + expect(deleteFeedback).toHaveBeenCalledWith('c1', 'm2', SUB); + }); +}); + +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 new file mode 100644 index 0000000..9cd14b5 --- /dev/null +++ b/api/src/slices/chat/myChat.controller.ts @@ -0,0 +1,301 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + Logger, + NotFoundException, + Param, + Post, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { Request, Response } 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, + ChatSyncService, + formatChatExport, +} from './domain'; +import { + ChatFeedbackDto, + ChatListResponseDto, + ChatMessagesQueryDto, + ChatMessagesResponseDto, + ChatSessionDto, + CreateChatFeedbackDto, + ExportChatQueryDto, + MyChatsQueryDto, + SyncChatsDto, + SyncChatsResponseDto, +} from './dtos'; + +type AuthedRequest = Request & { user?: IAuthTokenPayload }; + +const OWN_CHANNEL = 'bridle'; +const APP_FEEDBACK_SOURCE = 'app'; + +// End users never see tool-call debug events — hard-capped, no `types` override, +// and export is capped the same way (no tool events leak, even in json). +const USER_TYPES: TranscriptMessage['role'][] = [ + 'user', + 'assistant', + 'summary', +]; + +/** + * 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 every id in the path is ownership-checked. + */ +@ApiTags('chats') +@ApiBearerAuth() +@Controller('me/chats') +@UseGuards(JwtAuthGuard) +export class MyChatController { + private readonly logger = new Logger(MyChatController.name); + + constructor( + private readonly chats: IChatGateway, + private readonly reader: TranscriptReaderService, + private readonly sync: ChatSyncService, + ) {} + + @ApiOperation({ + description: "List the current user's own chat sessions, newest first.", + operationId: 'getMyChats', + }) + @ApiOkResponse({ type: ChatListResponseDto }) + @Get() + async list( + @Query() q: MyChatsQueryDto, + @Req() req: AuthedRequest, + ): Promise { + const page = q.page ?? 1; + const perPage = q.perPage ?? 50; + const { items, total } = await this.chats.list({ + channel: OWN_CHANNEL, + externalUserId: this.ownExternalId(req), + archived: q.archived ?? false, + page, + perPage, + }); + return { items, total, page, perPage }; + } + + @ApiOperation({ + description: "Get one of the current user's own chat sessions.", + operationId: 'getMyChat', + }) + @ApiOkResponse({ type: ChatSessionDto }) + @Get(':id') + async detail( + @Param('id') id: string, + @Req() req: AuthedRequest, + ): Promise { + return this.requireOwned(id, this.ownExternalId(req)); + } + + @ApiOperation({ + description: + "Replay one of the current user's own chats, tail-first. `summary` " + + 'markers are shown inline; tool events are never exposed to end users.', + operationId: 'getMyChatMessages', + }) + @ApiOkResponse({ type: ChatMessagesResponseDto }) + @Get(':id/messages') + async messages( + @Param('id') id: string, + @Query() q: ChatMessagesQueryDto, + @Req() req: AuthedRequest, + ): Promise { + const session = await this.requireOwned(id, this.ownExternalId(req)); + + const path = `data/sessions/${session.sessionKey}.jsonl`; + let all: TranscriptMessage[]; + try { + all = await this.reader.read(session.agentId, path, { + types: USER_TYPES, + filterTransient: true, + }); + } catch (err) { + 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 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); + } + + @ApiOperation({ + description: "Set the current user's 👍/👎 on a message in their own chat.", + operationId: 'createMyChatFeedback', + }) + @ApiOkResponse({ type: ChatFeedbackDto }) + @Post(':id/feedback') + async createFeedback( + @Param('id') id: string, + @Body() dto: CreateChatFeedbackDto, + @Req() req: AuthedRequest, + ): Promise { + await this.requireOwned(id, this.ownExternalId(req)); + return this.chats.upsertFeedback({ + sessionId: id, + messageId: dto.messageId, + rating: dto.rating, + comment: dto.comment, + source: APP_FEEDBACK_SOURCE, + // authorId is the caller's real user id (`sub`), NOT the resolved channel + // id — two admins on the shared 'admin' channel are still distinct authors. + authorId: this.requireSub(req), + }); + } + + @ApiOperation({ + description: "Clear the current user's feedback on a message (toggle-off).", + operationId: 'deleteMyChatFeedback', + }) + @HttpCode(204) + @Delete(':id/feedback/:messageId') + async deleteFeedback( + @Param('id') id: string, + @Param('messageId') messageId: string, + @Req() req: AuthedRequest, + ): Promise { + await this.requireOwned(id, this.ownExternalId(req)); + await this.chats.deleteFeedback(id, messageId, this.requireSub(req)); + } + + @ApiOperation({ + description: "List the current user's own feedback for one of their chats.", + operationId: 'listMyChatFeedback', + }) + @ApiOkResponse({ type: [ChatFeedbackDto] }) + @Get(':id/feedback') + async listFeedback( + @Param('id') id: string, + @Req() req: AuthedRequest, + ): Promise { + await this.requireOwned(id, this.ownExternalId(req)); + return this.chats.listFeedbackByAuthor(id, this.requireSub(req)); + } + + @ApiOperation({ + description: + "Download the current user's own chat as json / markdown / csv.", + operationId: 'exportMyChat', + }) + // @Res() bypasses the global {success,data} interceptor so we return the raw + // file with download headers (same pattern as the admin export). + @Get(':id/export') + async export( + @Param('id') id: string, + @Query() q: ExportChatQueryDto, + @Req() req: AuthedRequest, + @Res() res: Response, + ): Promise { + const session = await this.requireOwned(id, this.ownExternalId(req)); + const format = q.format ?? 'json'; + const path = `data/sessions/${session.sessionKey}.jsonl`; + let messages: TranscriptMessage[] = []; + try { + messages = await this.reader.read(session.agentId, path, { + types: USER_TYPES, // no tool events for end users, even in json + filterTransient: true, + }); + } catch (err) { + this.logger.warn( + `export read failed for ${session.agentId}/${session.sessionKey}: ${(err as Error).message}`, + ); + } + + const { body, contentType, ext } = formatChatExport( + format, + session, + messages, + ); + const safeKey = session.sessionKey.replace(/[^\w.-]/g, '_'); + res.setHeader('Content-Type', contentType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="chat-${safeKey}.${ext}"`, + ); + res.end(body); + } + + /** + * 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 = this.requireSub(req); + const roles = req.user?.roles ?? []; + const isAdmin = + roles.includes(UserRoleTypes.Owner) || + roles.includes(UserRoleTypes.Admin); + return isAdmin ? 'admin' : sub; + } + + private requireSub(req: AuthedRequest): string { + const sub = req.user?.sub; + if (!sub) throw new ForbiddenException('No authenticated user'); + return sub; + } + + /** + * Fetch a session and prove the caller owns it. Returns 404 (not 403) on a + * mismatch so we never confirm the existence of someone else's chat. + */ + private async requireOwned( + id: string, + externalUserId: string, + ): Promise { + const session = await this.chats.findById(id); + if ( + !session || + session.channel !== OWN_CHANNEL || + session.externalUserId !== externalUserId + ) { + throw new NotFoundException(`Chat ${id} not found`); + } + return session; + } +} 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, diff --git a/app/slices/chat/components/chatDetail/Provider.vue b/app/slices/chat/components/chatDetail/Provider.vue new file mode 100644 index 0000000..ab8d211 --- /dev/null +++ b/app/slices/chat/components/chatDetail/Provider.vue @@ -0,0 +1,221 @@ + + + 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..5ddd834 --- /dev/null +++ b/app/slices/chat/components/chatList/Provider.vue @@ -0,0 +1,102 @@ + + + diff --git a/app/slices/chat/components/chatMessage/Bubble.vue b/app/slices/chat/components/chatMessage/Bubble.vue new file mode 100644 index 0000000..84e383e --- /dev/null +++ b/app/slices/chat/components/chatMessage/Bubble.vue @@ -0,0 +1,199 @@ + + + + + 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..3b3da68 --- /dev/null +++ b/app/slices/chat/stores/chat.ts @@ -0,0 +1,187 @@ +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 { + 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; +} + +// 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 { + 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; + insights: IChatInsights | 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 interface IChatSyncResult { + scannedAgents: number; + scannedFiles: number; + upserted: number; + skipped: number; +} + +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, + } + ); + } + + // Self-service reconcile — pull the caller's own chats from S3 into the index + // when realtime indexing hasn't caught up. Server-scoped to the current user. + async function syncMine(agentId?: string): Promise { + const res = await ChatsService.syncMyChats({ + body: agentId ? { agentId } : {}, + }); + return unwrap(res.data); + } + + 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/common/components/layout/Provider.vue b/app/slices/common/components/layout/Provider.vue index 06e9c77..671cc99 100644 --- a/app/slices/common/components/layout/Provider.vue +++ b/app/slices/common/components/layout/Provider.vue @@ -21,6 +21,14 @@ > Agents + + History +
@@ -60,10 +68,18 @@
-
- -
-
+ +
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..ff51c74 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,255 @@ 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 CreateChatFeedbackDtoSchema = { + type: "object", + properties: { + messageId: { + type: "string", + description: "Event.id of the rated assistant message", + }, + rating: { + type: "number", + enum: [1, -1], + description: "1 = 👍, -1 = 👎", + }, + comment: { + type: "string", + }, + }, + required: ["messageId", "rating"], +} as const; + +export const ChatFeedbackDtoSchema = { + type: "object", + properties: { + id: { + type: "string", + }, + messageId: { + type: "string", + }, + rating: { + type: "number", + enum: [1, -1], + }, + comment: { + type: "object", + nullable: true, + }, + source: { + type: "string", + example: "admin", + }, + authorId: { + type: "object", + nullable: true, + }, + createdAt: { + format: "date-time", + type: "string", + }, + }, + required: ["id", "messageId", "rating", "source", "createdAt"], +} as const; + export const TelegramChannelConfigDtoSchema = { 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..6630182 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,38 @@ import type { SkillControllerFindByIdData, SkillControllerUpdateData, FindDependentAgentsData, + GetChatsData, + GetChatsResponse, + GetChatData, + GetChatResponse, + GetChatMessagesData, + GetChatMessagesResponse, + SyncChatsData, + SyncChatsResponse, + SummarizeChatData, + SummarizeChatResponse, + GetMyChatFeedbackData, + GetMyChatFeedbackResponse, + CreateChatFeedbackData, + CreateChatFeedbackResponse, + DeleteChatFeedbackData, + DeleteChatFeedbackResponse, + ExportChatData, + GetMyChatsData, + GetMyChatsResponse, + GetMyChatData, + GetMyChatResponse, + GetMyChatMessagesData, + GetMyChatMessagesResponse, + SyncMyChatsData, + SyncMyChatsResponse, + ListMyChatFeedbackData, + ListMyChatFeedbackResponse, + CreateMyChatFeedbackData, + CreateMyChatFeedbackResponse, + DeleteMyChatFeedbackData, + DeleteMyChatFeedbackResponse, + ExportMyChatData, GetAgentChannelsData, GetAgentChannelsResponse, SetAgentChannelsData, @@ -1960,6 +1992,296 @@ 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, + }); + } + + /** + * List the current user’s feedback for a chat session. + */ + public static getMyChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + GetMyChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/feedback", + ...options, + }); + } + + /** + * Set the current user’s 👍/👎 on an assistant message. + */ + public static createChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).post< + CreateChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/feedback", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + } + + /** + * Clear the current user’s feedback on a message (toggle-off). + */ + public static deleteChatFeedback( + options: Options, + ) { + return (options.client ?? _heyApiClient).delete< + DeleteChatFeedbackResponse, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/feedback/{messageId}", + ...options, + }); + } + + /** + * Download a chat transcript as json / markdown / csv. + */ + public static exportChat( + options: Options, + ) { + return (options.client ?? _heyApiClient).get< + unknown, + unknown, + ThrowOnError + >({ + url: "/chats/{id}/export", + ...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, + }); + } + + /** + * 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 { /** * List all users 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..e4d193e 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,122 @@ 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 CreateChatFeedbackDto = { + /** + * Event.id of the rated assistant message + */ + messageId: string; + /** + * 1 = 👍, -1 = 👎 + */ + rating: 1 | -1; + comment?: string; +}; + +export type ChatFeedbackDto = { + id: string; + messageId: string; + rating: 1 | -1; + comment?: { + [key: string]: unknown; + } | null; + source: string; + authorId?: { + [key: string]: unknown; + } | null; + createdAt: string; +}; + export type TelegramChannelConfigDto = { /** * Telegram bot HTTP API token (issued by @BotFather). @@ -2393,6 +2509,318 @@ 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 GetMyChatFeedbackData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}/feedback"; +}; + +export type GetMyChatFeedbackResponses = { + 200: Array; +}; + +export type GetMyChatFeedbackResponse = + GetMyChatFeedbackResponses[keyof GetMyChatFeedbackResponses]; + +export type CreateChatFeedbackData = { + body: CreateChatFeedbackDto; + path: { + id: string; + }; + query?: never; + url: "/chats/{id}/feedback"; +}; + +export type CreateChatFeedbackResponses = { + 200: ChatFeedbackDto; +}; + +export type CreateChatFeedbackResponse = + CreateChatFeedbackResponses[keyof CreateChatFeedbackResponses]; + +export type DeleteChatFeedbackData = { + body?: never; + path: { + id: string; + messageId: string; + }; + query?: never; + url: "/chats/{id}/feedback/{messageId}"; +}; + +export type DeleteChatFeedbackResponses = { + 204: void; +}; + +export type DeleteChatFeedbackResponse = + DeleteChatFeedbackResponses[keyof DeleteChatFeedbackResponses]; + +export type ExportChatData = { + body?: never; + path: { + id: string; + }; + query?: { + /** + * Download format. json = raw messages, markdown/csv = transcript. + */ + format?: "json" | "markdown" | "csv"; + }; + url: "/chats/{id}/export"; +}; + +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 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/app/slices/setup/theme/assets/css/tailwind.css b/app/slices/setup/theme/assets/css/tailwind.css index a364b31..3f9dd39 100644 --- a/app/slices/setup/theme/assets/css/tailwind.css +++ b/app/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/setup/theme/components/badge/Badge.vue b/app/slices/setup/theme/components/badge/Badge.vue new file mode 100644 index 0000000..ba4cc9b --- /dev/null +++ b/app/slices/setup/theme/components/badge/Badge.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/slices/setup/theme/components/badge/index.ts b/app/slices/setup/theme/components/badge/index.ts new file mode 100644 index 0000000..0cc92cc --- /dev/null +++ b/app/slices/setup/theme/components/badge/index.ts @@ -0,0 +1,24 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) +export type BadgeVariants = VariantProps 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) => { diff --git a/k8s/deploy/30-api.yaml b/k8s/deploy/30-api.yaml index 076ff70..9a5347f 100644 --- a/k8s/deploy/30-api.yaml +++ b/k8s/deploy/30-api.yaml @@ -86,6 +86,11 @@ spec: value: /usr/src/app/rancher/.agent - name: RANCHER_PADDOCK_DIR value: /usr/src/app/rancher/.paddock/scenarios + # Chat insight cron-batch: LLM-summarize settled chats every N seconds + # (unset / <= 0 disables it; on-demand POST /chats/:id/summarize still + # works either way — see ChatInsightService). + - name: CHAT_INSIGHT_INTERVAL_SEC + value: "900" # Browser-pool — shared headless Chromium for runtime tools. # Internal URL is reached over the in-cluster Service; public URL # is the user-facing host for VNC live views during 2FA logins. diff --git a/k8s/platform/api/deployment.yaml b/k8s/platform/api/deployment.yaml index c5c740c..26dca02 100644 --- a/k8s/platform/api/deployment.yaml +++ b/k8s/platform/api/deployment.yaml @@ -45,6 +45,11 @@ spec: value: /usr/src/app/rancher/.agent - name: RANCHER_PADDOCK_DIR value: /usr/src/app/rancher/.paddock/scenarios + # Chat insight cron-batch: LLM-summarize settled chats every N seconds + # (unset / <= 0 disables it; on-demand POST /chats/:id/summarize still + # works either way — see ChatInsightService). + - name: CHAT_INSIGHT_INTERVAL_SEC + value: "900" volumeMounts: - name: paddock-work mountPath: /usr/src/app/runtime/.paddock