Add hybrid R4R media conversation mode

This commit is contained in:
jjbrunton committed 2026-08-09 11:45:45 +01:00
1 parent 927636ec8b
commit b73e305cf3
15 files changed
+357 -35

No files matched your search

@@ -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) {
@@ -14,8 +14,9 @@ export interface SubredditDraft {
weight: string;
tone: z.infer<typeof ToneKeySchema>;
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<string, string> = {};
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 };
@@ -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
>
<option value="legacy">Legacy media asks</option>
<option value="r4r">Autonomous R4R conversation</option>
<option value="r4r-media">R4R then one media ask</option>
<option value="manual">Manual only</option>
</select>
<small>{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.'}</small>
</label>
{subreddit.conversationMode === 'r4r' ? (
{subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media' ? (
<label className="settings-field">
<span>Maximum automated turns</span>
<input
@@ -227,6 +230,23 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
{errors[`subreddits.${index}.maxAutomatedTurns`] ? <small className="field-error" id={`subreddit-${index}-max-turns-error`}>{errors[`subreddits.${index}.maxAutomatedTurns`]}</small> : null}
</label>
) : null}
{subreddit.conversationMode === 'r4r-media' ? (
<label className="settings-field">
<span>Rapport turns before media ask</span>
<input
aria-describedby={errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? `subreddit-${index}-rapport-turns-error` : undefined}
aria-invalid={errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} rapport turns before media ask`}
max="19"
min="1"
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, rapportTurnsBeforeMediaAsk: event.target.value }))}
step="1"
type="number"
value={subreddit.rapportTurnsBeforeMediaAsk}
/>
{errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? <small className="field-error" id={`subreddit-${index}-rapport-turns-error`}>{errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`]}</small> : null}
</label>
) : null}
<label className="settings-field scripted-field">
<span>Scripted message (optional)</span>
<textarea