diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index d3011eb..2bfffda 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -32,6 +32,7 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp {message.isSelf ? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}` : } + {message.isAi ? AI : null}

{message.text}

diff --git a/apps/dashboard/src/features/conversations/conversations.css b/apps/dashboard/src/features/conversations/conversations.css index 718798d..f0e2946 100644 --- a/apps/dashboard/src/features/conversations/conversations.css +++ b/apps/dashboard/src/features/conversations/conversations.css @@ -123,3 +123,17 @@ text-align: left; } } +.conversation-ai-badge { + display: inline-flex; + align-items: center; + min-height: 1.25rem; + padding: 0.125rem 0.4rem; + border: 1px solid rgba(99, 102, 241, 0.35); + border-radius: 999px; + background: rgba(99, 102, 241, 0.12); + color: #5558c9; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.06em; + line-height: 1; +} diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts index 0be6ebd..22f4065 100644 --- a/packages/api-contracts/src/chats.ts +++ b/packages/api-contracts/src/chats.ts @@ -13,6 +13,7 @@ export const ChatMessageSchema = z.strictObject({ text: z.string(), timestamp: z.number(), isSelf: z.boolean(), + isAi: z.boolean().optional(), media: z.array(ChatMediaSchema).optional(), }); export const ActiveChatSchema = z.strictObject({ diff --git a/src/services/conversation-automation.ts b/src/services/conversation-automation.ts index 6c2bc7b..2a142d3 100644 --- a/src/services/conversation-automation.ts +++ b/src/services/conversation-automation.ts @@ -7,6 +7,7 @@ import type { ChatService } from './chat/service.js'; import { buildConversationState, chooseConversationIntent, + conversationMessageFingerprint, MAX_AUTOMATED_CONVERSATION_TURNS, mergeConversationState, remainingAutomationTurns, @@ -101,6 +102,7 @@ export class ConversationAutomationService { state.askedForMedia = true; state.automation.turnsUsed += 1; state.automation.lastIncomingKey = incomingKey; + state.automation.aiMessageFingerprints.push(conversationMessageFingerprint(reply)); const exhausted = state.automation.turnsUsed >= MAX_AUTOMATED_CONVERSATION_TURNS; if (exhausted) { state.automation.status = 'human-required'; diff --git a/src/services/conversation-state.ts b/src/services/conversation-state.ts index ea1e6a3..f377195 100644 --- a/src/services/conversation-state.ts +++ b/src/services/conversation-state.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + export interface ConversationMessage { sender: string; text: string; @@ -45,6 +47,8 @@ export interface ConversationAutomationState { status: ConversationAutomationStatus; lastIncomingKey: string | null; handoffReason: string | null; + /** SHA-256 fingerprints avoid storing a second copy of generated message text. */ + aiMessageFingerprints: string[]; } export function createConversationAutomationState(): ConversationAutomationState { @@ -53,9 +57,14 @@ export function createConversationAutomationState(): ConversationAutomationState status: 'active', lastIncomingKey: null, handoffReason: null, + aiMessageFingerprints: [], }; } +export function conversationMessageFingerprint(message: string): string { + return createHash('sha256').update(message.trim()).digest('hex'); +} + export function remainingAutomationTurns(state: ConversationState): number { if (state.automation.status !== 'active') return 0; return Math.max(0, MAX_AUTOMATED_CONVERSATION_TURNS - state.automation.turnsUsed); @@ -222,6 +231,9 @@ function parseAutomationState(value: unknown): ConversationAutomationState { status, lastIncomingKey: typeof parsed.lastIncomingKey === 'string' ? parsed.lastIncomingKey : null, handoffReason: typeof parsed.handoffReason === 'string' ? parsed.handoffReason : null, + aiMessageFingerprints: Array.isArray(parsed.aiMessageFingerprints) + ? parsed.aiMessageFingerprints.filter((item): item is string => typeof item === 'string') + : [], }; } diff --git a/src/web/routes/conversations.ts b/src/web/routes/conversations.ts index a7ffd58..28fe9f8 100644 --- a/src/web/routes/conversations.ts +++ b/src/web/routes/conversations.ts @@ -4,6 +4,7 @@ import type { WebContext } from '../context.js'; import { buildConversationState, chooseConversationIntent, + conversationMessageFingerprint, mergeConversationState, remainingAutomationTurns, } from '../../services/conversation-state.js'; @@ -169,12 +170,14 @@ export function createConversationsRouter(ctx: WebContext): Router { imageUrl: string | null; sentAt: number; } | null = null; + let automatedOpener: string | null = null; try { const messagesRepo = resolved.runtime?.resources.messagesRepo ?? ctx.repos.messages; const sent = conversation.roomId ? messagesRepo.getByChatRoomId(conversation.roomId) ?? messagesRepo.getLatestByRecipient(conversation.username) : messagesRepo.getLatestByRecipient(conversation.username); + automatedOpener = sent?.message ?? null; if (sent && sent.postId) { const url = sent.postUrl ?? (sent.subreddit ? `https://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/` @@ -205,9 +208,25 @@ export function createConversationsRouter(ctx: WebContext): Router { intelligence.state.automation.handoffReason = intelligence.state.boundaries.join('; '); } if (conversation.roomId) stateRepo?.save(conversation.roomId, intelligence.state); + const aiFingerprints = new Map(); + if (automatedOpener) { + aiFingerprints.set(conversationMessageFingerprint(automatedOpener), 1); + } + for (const fingerprint of intelligence.state.automation.aiMessageFingerprints) { + aiFingerprints.set(fingerprint, (aiFingerprints.get(fingerprint) ?? 0) + 1); + } + const annotatedMessages = conversation.messages?.map((message) => { + if (!message.isSelf) return message; + const fingerprint = conversationMessageFingerprint(message.text); + const remainingMatches = aiFingerprints.get(fingerprint) ?? 0; + if (remainingMatches === 0) return message; + aiFingerprints.set(fingerprint, remainingMatches - 1); + return { ...message, isAi: true }; + }); res.json({ ...conversation, + ...(annotatedMessages ? { messages: annotatedMessages } : {}), owner: resolved.runtime ? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername } : currentOwner(), diff --git a/tests/conversation-automation.test.ts b/tests/conversation-automation.test.ts index 8a1ea6a..30cb23e 100644 --- a/tests/conversation-automation.test.ts +++ b/tests/conversation-automation.test.ts @@ -46,6 +46,7 @@ describe('conversation automation', () => { expect(third).toMatchObject({ kind: 'human-required', remainingTurns: 0 }); expect(chat.sendChatMessage).toHaveBeenCalledTimes(3); expect(getSaved()?.automation).toMatchObject({ turnsUsed: 3, status: 'human-required' }); + expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(3); expect(await service.processConversation(incoming(3))).toBeNull(); }); diff --git a/tests/dashboard/conversations.test.tsx b/tests/dashboard/conversations.test.tsx index cc77808..e204f5e 100644 --- a/tests/dashboard/conversations.test.tsx +++ b/tests/dashboard/conversations.test.tsx @@ -40,7 +40,7 @@ function detailFixture(chat = alice) { return { ...chat, messages: [ - { sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true }, + { sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true, isAi: true }, { sender: 'alice', text: 'Good to hear from you', timestamp: 1_786_000_000_000, isSelf: false }, ], triggeringPost: { @@ -136,6 +136,7 @@ describe('ConversationsPage', () => { expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice'); } expect(screen.getByText('Good to hear from you')).toBeInTheDocument(); + expect(screen.getByLabelText('Sent by AI')).toHaveTextContent('AI'); expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1')); expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute( 'src', diff --git a/tests/openrouter-client.test.ts b/tests/openrouter-client.test.ts index 4176db7..c11cfe4 100644 --- a/tests/openrouter-client.test.ts +++ b/tests/openrouter-client.test.ts @@ -574,7 +574,7 @@ describe('OpenRouterClient', () => { boundaries: [], askedForMedia: false, topics: [], - automation: { turnsUsed: 0, status: 'active', lastIncomingKey: null, handoffReason: null }, + automation: { turnsUsed: 0, status: 'active', lastIncomingKey: null, handoffReason: null, aiMessageFingerprints: [] }, }, { kind: 'answer-question', reason: 'their latest message asks a direct question' }, ); diff --git a/tests/web/conversations-route.test.ts b/tests/web/conversations-route.test.ts index 98aed96..b9ae6b7 100644 --- a/tests/web/conversations-route.test.ts +++ b/tests/web/conversations-route.test.ts @@ -3,6 +3,7 @@ import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { WebContext } from '../../src/web/context.js'; import { createConversationsRouter } from '../../src/web/routes/conversations.js'; +import { buildConversationState, conversationMessageFingerprint } from '../../src/services/conversation-state.js'; const servers: Array> = []; @@ -234,13 +235,14 @@ describe('conversations route', () => { lastMessageSnippet: 'Hello', isFromUs: false, unread: false, - messages: [{ - sender: 'alice', - text: 'Hello', - timestamp: 1_786_000_000_000, - isSelf: false, - }], + messages: [ + { sender: 'me', text: 'Automated hello', timestamp: 1_785_999_900_000, isSelf: true }, + { sender: 'alice', text: 'Hello', timestamp: 1_786_000_000_000, isSelf: false }, + ], }; + const savedState = buildConversationState(conversation.messages); + savedState.automation.turnsUsed = 1; + savedState.automation.aiMessageFingerprints = [conversationMessageFingerprint('Automated hello')]; const refreshTopChats = vi.fn(); const loadConversationDetails = vi.fn(); const context = { @@ -249,6 +251,7 @@ describe('conversations route', () => { messages: { getLatestByRecipient: vi.fn().mockReturnValue(undefined) }, }, services: { + conversationState: { get: vi.fn().mockReturnValue(savedState), save: vi.fn() }, chat: { getSyncStatus: vi.fn().mockReturnValue({ syncing: false, @@ -270,11 +273,13 @@ describe('conversations route', () => { const body = await response.json(); expect(body.username).toBe('alice'); expect(body.automation).toEqual({ - turnsUsed: 0, - remainingTurns: 3, + turnsUsed: 1, + remainingTurns: 2, status: 'active', handoffReason: null, }); + expect(body.messages[0]).toMatchObject({ text: 'Automated hello', isAi: true }); + expect(body.messages[1].isAi).toBeUndefined(); expect(refreshTopChats).not.toHaveBeenCalled(); expect(loadConversationDetails).not.toHaveBeenCalled(); }); @@ -310,10 +315,12 @@ describe('conversations route', () => { const response = await request(app, '/api/conversations/room-alice'); expect(response.status).toBe(200); - expect((await response.json()).triggeringPost).toEqual({ + const body = await response.json(); + expect(body.triggeringPost).toEqual({ id: 'post-exact', subreddit: 'example', title: 'Exact source post', url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, sentAt: 9, }); + expect(body.messages[0]).toMatchObject({ text: 'Hello', isAi: true }); expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled(); });