From b73e305cf3e9be56c58b831f3e2d0fdae52c14db Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 9 Aug 2026 11:45:45 +0100 Subject: [PATCH] Add hybrid R4R media conversation mode --- .../src/features/settings/settings-editor.tsx | 6 +- .../src/features/settings/settings-model.ts | 19 +++- .../features/settings/settings-sections.tsx | 24 ++++- docs/configuration.md | 9 ++ packages/api-contracts/src/config.ts | 13 ++- .../api-contracts/tests/contracts.test.ts | 2 +- src/ai/openrouter.ts | 30 +++++++ src/config/index.ts | 13 ++- src/services/conversation-automation.ts | 46 +++++++--- src/services/conversation-state.ts | 33 ++++++- tests/config.test.ts | 18 +++- tests/conversation-automation.test.ts | 87 ++++++++++++++++++- tests/conversation-state.test.ts | 13 ++- tests/dashboard/settings.test.tsx | 44 +++++++++- tests/openrouter-client.test.ts | 35 ++++++++ 15 files changed, 357 insertions(+), 35 deletions(-) diff --git a/apps/dashboard/src/features/settings/settings-editor.tsx b/apps/dashboard/src/features/settings/settings-editor.tsx index 43af148..504cf90 100644 --- a/apps/dashboard/src/features/settings/settings-editor.tsx +++ b/apps/dashboard/src/features/settings/settings-editor.tsx @@ -77,11 +77,11 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) { reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`); } const enablesAutonomousR4R = validation.data.payload.subreddits?.some((subreddit, index) => ( - subreddit.conversation.mode === 'r4r' - && persistedDraft.subreddits[index]?.conversationMode !== 'r4r' + (subreddit.conversation.mode === 'r4r' || subreddit.conversation.mode === 'r4r-media') + && persistedDraft.subreddits[index]?.conversationMode !== subreddit.conversation.mode )); if (enablesAutonomousR4R) { - reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. Existing conversations keep their snapshotted policy.'); + reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. The hybrid mode can make one optional image request after rapport. Existing conversations keep their snapshotted policy.'); } if (reasons.length > 0) { diff --git a/apps/dashboard/src/features/settings/settings-model.ts b/apps/dashboard/src/features/settings/settings-model.ts index 4ec9492..c430dd1 100644 --- a/apps/dashboard/src/features/settings/settings-model.ts +++ b/apps/dashboard/src/features/settings/settings-model.ts @@ -14,8 +14,9 @@ export interface SubredditDraft { weight: string; tone: z.infer; scripted: string; - conversationMode: 'legacy' | 'r4r' | 'manual'; + conversationMode: 'legacy' | 'r4r' | 'r4r-media' | 'manual'; maxAutomatedTurns: string; + rapportTurnsBeforeMediaAsk: string; } export interface SettingsDraft { @@ -77,6 +78,7 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft { scripted: subreddit.scripted ?? '', conversationMode: subreddit.conversation.mode, maxAutomatedTurns: String(subreddit.conversation.maxAutomatedTurns), + rapportTurnsBeforeMediaAsk: String(subreddit.conversation.rapportTurnsBeforeMediaAsk), })), advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '', }; @@ -93,9 +95,13 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) { requiredErrors[`subreddits.${index}.weight`] = 'Subreddit weight must be greater than 0.'; } - if (subreddit.conversationMode === 'r4r' && subreddit.maxAutomatedTurns.trim() === '') { + if ((subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media') + && subreddit.maxAutomatedTurns.trim() === '') { requiredErrors[`subreddits.${index}.maxAutomatedTurns`] = 'Maximum automated turns is required.'; } + if (subreddit.conversationMode === 'r4r-media' && subreddit.rapportTurnsBeforeMediaAsk.trim() === '') { + requiredErrors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] = 'Rapport turns is required.'; + } }); if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors }; @@ -128,9 +134,12 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR scripted: subreddit.scripted.trim() === '' ? undefined : subreddit.scripted, conversation: { mode: subreddit.conversationMode, - maxAutomatedTurns: subreddit.conversationMode === 'r4r' + maxAutomatedTurns: subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media' ? Number(subreddit.maxAutomatedTurns) : 3, + rapportTurnsBeforeMediaAsk: subreddit.conversationMode === 'r4r-media' + ? Number(subreddit.rapportTurnsBeforeMediaAsk) + : 3, }, })), }); @@ -138,7 +147,9 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR if (!parsed.success) { const errors: Record = {}; for (const issue of parsed.error.issues) { - const path = issue.path.join('.'); + const pathParts = [...issue.path]; + if (pathParts[0] === 'subreddits' && pathParts[2] === 'conversation') pathParts.splice(2, 1); + const path = pathParts.join('.'); if (!(path in errors)) errors[path] = issue.message; } return { errors }; diff --git a/apps/dashboard/src/features/settings/settings-sections.tsx b/apps/dashboard/src/features/settings/settings-sections.tsx index c31cde1..5fcf2af 100644 --- a/apps/dashboard/src/features/settings/settings-sections.tsx +++ b/apps/dashboard/src/features/settings/settings-sections.tsx @@ -122,7 +122,7 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro ...draft, subreddits: [ ...draft.subreddits, - { name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3' }, + { name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3', rapportTurnsBeforeMediaAsk: '3' }, ], })} type="button" @@ -202,15 +202,18 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro > + {subreddit.conversationMode === 'r4r' ? 'Continues general conversation autonomously. It will not request sexual media.' + : subreddit.conversationMode === 'r4r-media' + ? 'Builds rapport autonomously, then makes one optional, non-explicit image request.' : subreddit.conversationMode === 'manual' ? 'Incoming replies are marked for human handling and are never answered automatically.' : 'Preserves the existing three-turn media-request flow.'} - {subreddit.conversationMode === 'r4r' ? ( + {subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media' ? ( ) : null} + {subreddit.conversationMode === 'r4r-media' ? ( + + ) : null}