From cb8be3511167e4d215d3ad19405739aa8ffd7c5a Mon Sep 17 00:00:00 2001 From: Ben Marshall Date: Fri, 26 Jun 2026 14:59:48 -0500 Subject: [PATCH 1/5] =?UTF-8?q?feat(intimacy):=20AI=20suggester=20for=20ow?= =?UTF-8?q?ner=20intimacy=20topics=20(core,=2008=20=C2=A716.5a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An owner-only suggester: optionally type a subject (blank → a varied spread) and the model proposes consensual-adult activity + fantasy topics, deduped case-insensitively against the existing inventory. Persists nothing — the owner reviews + adds the chosen ones via the existing add path. One metered intimacy.suggestTopics pass (meter before parse, tolerant parse, honest failures); the consensual-adult boundary is in the prompt + the model. 4 core tests (dedup/meter/ground, no-subject, NO_KEY, EMPTY). Co-Authored-By: Claude Opus 4.8 --- packages/core/src/intimacy/index.ts | 1 + .../core/src/intimacy/suggestService.test.ts | 113 +++++++++++ packages/core/src/intimacy/suggestService.ts | 186 ++++++++++++++++++ packages/core/src/schemas.ts | 27 +++ packages/core/src/usageTypes.ts | 1 + 5 files changed, 328 insertions(+) create mode 100644 packages/core/src/intimacy/suggestService.test.ts create mode 100644 packages/core/src/intimacy/suggestService.ts diff --git a/packages/core/src/intimacy/index.ts b/packages/core/src/intimacy/index.ts index 1860e38..672ed75 100644 --- a/packages/core/src/intimacy/index.ts +++ b/packages/core/src/intimacy/index.ts @@ -1,3 +1,4 @@ export * from './topics'; export * from './activityRows'; export * from './grouping'; +export * from './suggestService'; diff --git a/packages/core/src/intimacy/suggestService.test.ts b/packages/core/src/intimacy/suggestService.test.ts new file mode 100644 index 0000000..9692e76 --- /dev/null +++ b/packages/core/src/intimacy/suggestService.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { generateMasterKey } from '../crypto'; +import { memFileSystem } from '../host/memFileSystem'; +import type { ClaudeClient, FileSystem } from '../host'; +import { queryUsage } from '../usage'; +import { type SuggestTopicsDeps, suggestIntimacyTopics } from './suggestService'; +import type { IntimacyTopics } from './topics'; + +const key = generateMasterKey(); +const now = new Date('2026-06-26T12:00:00.000Z'); +let fs: FileSystem; +beforeEach(() => { + fs = memFileSystem(); +}); + +/** A fake client capturing the system + the user brief and returning a JSON payload. */ +function jsonClient(payload: { activities: string[]; fantasies: string[] }): { + client: ClaudeClient; + system: () => string; + brief: () => string; +} { + let system = ''; + let brief = ''; + return { + system: () => system, + brief: () => brief, + client: { + send: () => Promise.resolve(''), + stream: (options) => { + system = options.system ?? ''; + brief = options.messages + .map((m) => (typeof m.content === 'string' ? m.content : '')) + .join('\n'); + return Promise.resolve({ + text: JSON.stringify(payload), + usage: { inputTokens: 80, outputTokens: 40, cacheWriteTokens: 0, cacheReadTokens: 0 }, + }); + }, + }, + }; +} + +const existing: IntimacyTopics = { activities: ['Sensual massage'], fantasies: ['Domination'] }; + +function deps(client: ClaudeClient, over: Partial = {}): SuggestTopicsDeps { + return { + fs, + key, + client, + apiKey: 'sk-ant', + model: 'claude-sonnet-4-6', + personId: 'owner-1', + now, + ...over, + }; +} + +describe('suggestIntimacyTopics (08 §16.5a AI assist)', () => { + it('returns deduped fresh activities + fantasies, meters before parse, and grounds the prompt', async () => { + const { client, system, brief } = jsonClient({ + activities: ['Sensual massage', 'Wax play', 'WAX PLAY', ' '], // existing dup + in-list dup + blank + fantasies: ['Voyeurism', 'Domination'], // 2nd is an existing dup + }); + const res = await suggestIntimacyTopics(deps(client), { subject: 'sensory play', existing }); + + expect(res.ok).toBe(true); + if (!res.ok) throw new Error('expected ok'); + expect(res.suggestions.activities).toEqual(['Wax play']); // existing + in-list dup + blank dropped + expect(res.suggestions.fantasies).toEqual(['Voyeurism']); // 'Domination' (existing) dropped + + // The subject + the avoid-list reached the model; the consensual-adult boundary is in the system prompt. + expect(brief()).toContain('sensory play'); + expect(brief()).toContain('Sensual massage'); // the "already have, do not repeat" list + expect(system()).toMatch(/consensual ADULTS only/i); + + // Metered as intimacy.suggestTopics (before parse / dedup). + const usage = await queryUsage(fs, key, { + from: '2026-06-01T00:00:00.000Z', + to: '2026-07-01T00:00:00.000Z', + personId: 'owner-1', + }); + expect(usage.some((u) => u.type === 'intimacy.suggestTopics')).toBe(true); + }); + + it('with no subject, asks for a varied spread (and still works)', async () => { + const { client, brief } = jsonClient({ activities: ['Edging together'], fantasies: [] }); + const res = await suggestIntimacyTopics(deps(client), { existing }); + expect(res.ok).toBe(true); + expect(brief()).toMatch(/No specific subject/i); + }); + + it('NO_KEY without an API key (no spend)', async () => { + const { client } = jsonClient({ activities: ['x'], fantasies: [] }); + const res = await suggestIntimacyTopics(deps(client, { apiKey: null }), { existing }); + expect(res.ok).toBe(false); + if (res.ok) throw new Error('expected failure'); + expect(res.reason).toBe('NO_KEY'); + const usage = await queryUsage(fs, key, { + from: '2026-06-01T00:00:00.000Z', + to: '2026-07-01T00:00:00.000Z', + personId: 'owner-1', + }); + expect(usage).toHaveLength(0); + }); + + it('EMPTY when every suggestion already exists (the model only echoed the inventory)', async () => { + const { client } = jsonClient({ activities: ['sensual massage'], fantasies: ['DOMINATION'] }); + const res = await suggestIntimacyTopics(deps(client), { existing }); + expect(res.ok).toBe(false); + if (res.ok) throw new Error('expected failure'); + expect(res.reason).toBe('EMPTY'); + }); +}); diff --git a/packages/core/src/intimacy/suggestService.ts b/packages/core/src/intimacy/suggestService.ts new file mode 100644 index 0000000..83def34 --- /dev/null +++ b/packages/core/src/intimacy/suggestService.ts @@ -0,0 +1,186 @@ +import { z } from 'zod'; +import { classifyParseOutcome, extractJsonObject } from '../ai/jsonSalvage'; +import type { ClaudeClient, FileSystem } from '../host'; +import type { IntimacyTopicSuggestResult, UsageEvent } from '../schemas'; +import { uuid } from '../id'; +import { checkBudget, costOf, recordUsage } from '../usage'; +import { PERSONA, SAFETY } from '../conversations/promptBuilder'; +import type { IntimacyTopics } from './topics'; + +/** + * The owner-only AI **intimacy-topic suggester** (08-questionnaires §16.5a, the AI-assist follow-up). The + * Owner optionally types a subject/theme and the model proposes consensual-adult **activity** + **fantasy** + * topics to add to the shared inventory — deduped against what already exists, in the calm wellness register + * the rest of the intimacy work uses. It PERSISTS NOTHING: the owner reviews the suggestions in a checklist, + * edits, and the existing add path commits the chosen ones. The only AI spend is one `intimacy.suggestTopics` + * pass, metered BEFORE parse (spec 06 / 37). + * + * Boundary (§8, in the prompt + the model, never a keyword filter): consensual adults only; taboo content + * strictly as fantasy/roleplay; never minors, real non-consent, or illegal acts. The Owner is the full-access + * role, so this is gated `people.manage` at the seam, like the manual add. + */ + +const MAX_PER_KIND = 12; + +function guidance(): string { + return `You are helping the Owner of a private wellness app curate a shared, consensual-adult intimacy \ +"topic inventory" — short labels (a few words each) that people later RATE in a self-reflection (e.g. \ +"Sensual massage", "Light bondage (cuffs / ties)"). These are topics to rate, NOT instructions or how-to \ +content. Write in a frank, plain, clinical-but-warm wellness register — like a sexual-health questionnaire, \ +never erotica. + +Suggest concise topics across two lists: "activities" (things partners do together) and "fantasies" \ +(themes/roleplay people fantasize about). Boundary: consensual ADULTS only; taboo content ONLY as clearly \ +pre-agreed fantasy/roleplay between adults (e.g. CNC as ravishment roleplay); NEVER anything involving \ +minors, real non-consent, incest, or illegal acts. Do not repeat any topic the Owner already has (a list is \ +provided to avoid). Keep each label short and rateable; aim for 6–10 fresh topics per list (fewer is fine if \ +the subject is narrow). If a subject is given, stay close to it; if not, suggest a varied spread across \ +gentle→adventurous. + +Respond with ONLY a JSON object: {"activities": string[], "fantasies": string[]}.`; +} + +function buildBrief(subject: string | undefined, existing: IntimacyTopics): string { + const subjectLine = subject?.trim() + ? `Subject to suggest around: ${subject.trim()}` + : 'No specific subject — suggest a varied spread across gentle→adventurous.'; + const avoid = [...existing.activities, ...existing.fantasies]; + // Bound the avoid-list so a huge custom inventory can't blow the prompt; the post-parse dedupe is the + // real guarantee, this just nudges the model. + const avoidLine = `Topics the Owner ALREADY has (do not repeat these): ${avoid.slice(0, 160).join(', ')}`; + return [subjectLine, avoidLine].join('\n\n'); +} + +const DraftSchema = z.object({ + activities: z.array(z.string()).catch([]).default([]), + fantasies: z.array(z.string()).catch([]).default([]), +}); + +/** Case-insensitive: keep only fresh, non-empty, in-list-unique labels not already in `taken`. */ +function freshen(values: string[], taken: Set): string[] { + const out: string[] = []; + const seen = new Set(taken); + for (const raw of values) { + const label = raw.trim(); + const norm = label.toLocaleLowerCase(); + if (label === '' || seen.has(norm)) continue; + seen.add(norm); + out.push(label); + if (out.length >= MAX_PER_KIND) break; + } + return out; +} + +function buildUsage( + model: string, + personId: string, + at: string, + usage: { + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; + }, +): UsageEvent { + return { + id: uuid(), + schemaVersion: 1, + type: 'intimacy.suggestTopics', + personId, + model, + at, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheWriteTokens: usage.cacheWriteTokens, + cacheReadTokens: usage.cacheReadTokens, + costUsd: costOf(model, usage), + }; +} + +export interface SuggestTopicsDeps { + fs: FileSystem; + key: Uint8Array; + client: ClaudeClient; + apiKey: string | null; + model: string; + /** The Owner — for usage attribution (the topics are household-wide). */ + personId: string; + now: Date; + override?: boolean; +} + +/** + * Run the suggester: budget-gated → one Claude call → meter (`intimacy.suggestTopics`, BEFORE parse) → + * tolerant parse → dedupe against the existing inventory (case-insensitive). Returns ephemeral candidates; + * nothing is written. An empty result after dedupe is an honest EMPTY (the model only echoed existing topics). + */ +export async function suggestIntimacyTopics( + deps: SuggestTopicsDeps, + input: { subject?: string; existing: IntimacyTopics }, +): Promise { + const { fs, key, client, apiKey, model, personId, now } = deps; + if (!apiKey) return { ok: false, reason: 'NO_KEY', message: 'Add your Claude API key first.' }; + + const person = await checkBudget(fs, key, { + scope: 'person', + personId, + now, + override: deps.override, + }); + const app = await checkBudget(fs, key, { scope: 'app', now, override: deps.override }); + if (person.state === 'over' || app.state === 'over') { + return { ok: false, reason: 'BUDGET', message: 'AI budget reached for this period.' }; + } + + const at = now.toISOString(); + let result; + try { + result = await client.stream( + { + apiKey, + model, + system: [PERSONA, SAFETY, guidance()].join('\n\n'), + messages: [{ role: 'user', content: buildBrief(input.subject, input.existing) }], + maxTokens: 800, + extendedThinking: false, // a bounded structured-JSON call — keep the whole budget for output + }, + () => {}, + ); + } catch { + return { + ok: false, + reason: 'ERROR', + message: 'The suggestions couldn’t be written. Please try again.', + }; + } + + // Meter BEFORE parse — a paid call whose JSON fails is still billed (spec 06 / 37). + await recordUsage(fs, key, buildUsage(model, personId, at, result.usage)); + + const obj = extractJsonObject(result.text); + const parsed = obj ? DraftSchema.safeParse(obj) : null; + if (!parsed?.success) { + const { reason, message } = classifyParseOutcome(result.text, 'topic suggestions'); + return { ok: false, reason, message }; + } + + const taken = new Set( + [...input.existing.activities, ...input.existing.fantasies].map((t) => t.toLocaleLowerCase()), + ); + const activities = freshen(parsed.data.activities, taken); + // Fantasies dedupe against the fantasy + activity sets too, so the two suggestion lists don't overlap. + const fantasies = freshen( + parsed.data.fantasies, + new Set([...taken, ...activities.map((a) => a.toLocaleLowerCase())]), + ); + + if (activities.length === 0 && fantasies.length === 0) { + return { + ok: false, + reason: 'EMPTY', + message: + 'No fresh topics came back — try a different subject, or you may already have them all.', + }; + } + return { ok: true, suggestions: { activities, fantasies } }; +} diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 47e6c89..8d97634 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -836,6 +836,33 @@ export type ChallengeSuggestionResult = message: string; }; +/** + * AI-suggested intimacy topics for the owner to review (08-questionnaires §16.5a, AI-assist follow-up). + * Deduped activity + fantasy candidates the owner picks/edits before adding to the shared inventory — the + * suggester PERSISTS NOTHING (the owner's "Add selected" reuses the existing add path). The only AI spend is + * the `intimacy.suggestTopics` pass, owner-gated + metered before parse. + */ +export interface IntimacyTopicSuggestions { + activities: string[]; + fantasies: string[]; +} + +export type IntimacyTopicSuggestResult = + | { ok: true; suggestions: IntimacyTopicSuggestions } + | { + ok: false; + reason: + | 'NO_KEY' + | 'BUDGET' + | 'AI_OFF' + | 'EMPTY' + | 'REFUSED' + | 'TRUNCATED' + | 'MALFORMED' + | 'ERROR'; + message: string; + }; + /** * The result of an inline check-in (52 §6). The status + outcome ALWAYS persist (free, no AI); the optional * reflection → Insight bridge (§5.4) is deterministic in v1, so a check-in never spends. `challenge` carries diff --git a/packages/core/src/usageTypes.ts b/packages/core/src/usageTypes.ts index adda009..205b2e1 100644 --- a/packages/core/src/usageTypes.ts +++ b/packages/core/src/usageTypes.ts @@ -21,6 +21,7 @@ export const USAGE_TYPE_LABELS: Record = { 'relationship.synthesize': 'Memory — relationship insights', 'test.narrate': 'Self-assessment — what it means', 'challenge.suggest': 'Challenge suggestion', + 'intimacy.suggestTopics': 'Intimacy topics — AI suggestions', }; export function usageTypeLabel(type: string): string { From 6f1a6fc0057db9a7a33578e19569cddccf467473 Mon Sep 17 00:00:00 2001 From: Ben Marshall Date: Fri, 26 Jun 2026 15:09:07 -0500 Subject: [PATCH 2/5] =?UTF-8?q?feat(settings):=20owner=20AI=20suggester=20?= =?UTF-8?q?for=20intimacy=20topics=20=E2=80=94=20seam=20+=20checklist=20UI?= =?UTF-8?q?=20(08=20=C2=A716.5a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the suggester end-to-end and adds it to the existing owner-only Intimacy Topics control: a subject box (blank -> a varied mix) + "Suggest with AI" -> a pick-and-edit checklist (every suggestion ticked + editable) -> "Add selected" commits the chosen ones via the existing add path. Owner-only (people.manage), AI-off is calm, deduped against the inventory. IPC questionnaires:suggestIntimacyTopics + the offline fake-Claude branch. 6 IntimacyTopicsControl RTL (suggest/pick/edit/add + the no-leak EMPTY + non-owner hidden) + a coreBridge test (owner deduped suggestions, member denied, AI-off calm). Co-Authored-By: Claude Opus 4.8 --- .../src/main/claude/anthropicClient.ts | 13 ++ apps/desktop/src/main/ipc.ts | 4 + apps/desktop/src/preload/index.ts | 2 + .../settings/IntimacyTopicsControl.module.css | 33 ++++ .../settings/IntimacyTopicsControl.test.tsx | 55 ++++++ .../src/settings/IntimacyTopicsControl.tsx | 176 +++++++++++++++++- .../src/renderer/src/test-utils/bridge.ts | 5 + apps/desktop/src/shared/channels.ts | 6 + apps/desktop/src/shared/coreBridge.test.ts | 41 ++++ apps/desktop/src/shared/coreBridge.ts | 27 ++- 10 files changed, 356 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/claude/anthropicClient.ts b/apps/desktop/src/main/claude/anthropicClient.ts index 47eb62d..b0872ed 100644 --- a/apps/desktop/src/main/claude/anthropicClient.ts +++ b/apps/desktop/src/main/claude/anthropicClient.ts @@ -234,6 +234,19 @@ export function fakeClaudeClient(): ClaudeClient { }); } + // The owner intimacy-topic suggester (08 §16.5a AI assist) — the brief lists what the owner already + // has. Return a small {activities, fantasies} set; include one EXISTING topic ('Sensual massage', a + // built-in) so the post-parse dedupe is exercised in the offline path (37 §10). + if (userText.includes('Topics the Owner ALREADY has')) { + return Promise.resolve({ + text: JSON.stringify({ + activities: ['Sensual massage', 'Mutual edging', 'Temperature contrast play'], + fantasies: ['Rivals-to-lovers roleplay', 'Voyeurism'], + }), + usage: { inputTokens: 90, outputTokens: 50, cacheWriteTokens: 0, cacheReadTokens: 0 }, + }); + } + // The session-analysis turn (09 §5) asks to "summarize this session" as a JSON object. Return a // valid SessionAnalysisDraft so the offline End & summarize path parses + produces facts/mood. if (userText.includes('summarize this session')) { diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 47ae396..a2a5ee5 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -291,6 +291,10 @@ export function registerIpcHandlers(): void { handle(IpcChannels.questionnairesIntimacyTopics, bridge.questionnairesIntimacyTopics); handle(IpcChannels.questionnairesAddIntimacyTopic, bridge.questionnairesAddIntimacyTopic); handle(IpcChannels.questionnairesRemoveIntimacyTopic, bridge.questionnairesRemoveIntimacyTopic); + handle( + IpcChannels.questionnairesSuggestIntimacyTopics, + bridge.questionnairesSuggestIntimacyTopics, + ); handle(IpcChannels.questionnairesStoreImage, bridge.questionnairesStoreImage); handle(IpcChannels.questionnairesGetImage, bridge.questionnairesGetImage); handle(IpcChannels.questionnairesDeleteImage, bridge.questionnairesDeleteImage); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index c032d2e..2c70768 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -132,6 +132,8 @@ const bridge: SelfosBridge = { ipcRenderer.invoke(IpcChannels.questionnairesAddIntimacyTopic, input), questionnairesRemoveIntimacyTopic: (input) => ipcRenderer.invoke(IpcChannels.questionnairesRemoveIntimacyTopic, input), + questionnairesSuggestIntimacyTopics: (input) => + ipcRenderer.invoke(IpcChannels.questionnairesSuggestIntimacyTopics, input), questionnairesStoreImage: (input) => ipcRenderer.invoke(IpcChannels.questionnairesStoreImage, input), questionnairesGetImage: (imagePath) => diff --git a/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.module.css b/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.module.css index a8f082c..066c23e 100644 --- a/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.module.css +++ b/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.module.css @@ -34,3 +34,36 @@ background: var(--color-border); color: var(--color-text-primary); } + +/* The owner-only "Suggest with AI" block — a calm bordered card distinct from the manual add rows. */ +.suggestBox { + padding: var(--space-3); + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.suggestions { + padding-top: var(--space-1); +} + +/* One reviewable suggestion: a checkbox + an editable label, on a single row. */ +.pickRow { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.pickRow > :last-child { + flex: 1 1 auto; + min-width: 0; +} + +.pickCheck { + flex: none; + width: 16px; + height: 16px; + accent-color: var(--color-accent); + cursor: pointer; +} diff --git a/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.test.tsx b/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.test.tsx index 74e1e88..06ed9b6 100644 --- a/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.test.tsx +++ b/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.test.tsx @@ -96,5 +96,60 @@ describe('IntimacyTopicsControl (§16.5a)', () => { expect(screen.getByText('Wax play')).toBeInTheDocument(); // still shown, just not removable expect(screen.queryByRole('button', { name: 'Remove Wax play' })).not.toBeInTheDocument(); expect(screen.queryByLabelText(/Add activities/i)).not.toBeInTheDocument(); + // The owner-only AI suggester is hidden for a non-owner. + expect(screen.queryByRole('button', { name: 'Suggest with AI' })).not.toBeInTheDocument(); + }); + + it('suggests with AI, then adds only the picked + edited suggestions', async () => { + asOwner(); + const add = vi.fn((input: { kind: 'activities' | 'fantasies'; name: string }) => + Promise.resolve(view({ activities: [input.name], fantasies: [] })), + ); + const suggest = vi.fn(() => + Promise.resolve({ + ok: true as const, + suggestions: { activities: ['Wax play', 'Edging'], fantasies: ['Roleplay'] }, + }), + ); + installMockBridge({ + questionnairesIntimacyTopics: () => Promise.resolve(view({ activities: [], fantasies: [] })), + questionnairesSuggestIntimacyTopics: suggest, + questionnairesAddIntimacyTopic: add, + }); + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Suggest with AI' })); + // Suggestions render as an editable checklist (all checked by default). + expect(await screen.findByLabelText('Edit suggestion: Wax play')).toBeInTheDocument(); + // Uncheck 'Edging'. + await userEvent.click(screen.getByLabelText('Include Edging')); + // Edit 'Roleplay' → 'Pirate roleplay' (the label keys on the original text, so it still resolves). + const roleplay = screen.getByLabelText('Edit suggestion: Roleplay'); + await userEvent.clear(roleplay); + await userEvent.type(roleplay, 'Pirate roleplay'); + + await userEvent.click(screen.getByRole('button', { name: /Add selected \(2\)/ })); + await waitFor(() => { + expect(add).toHaveBeenCalledWith({ kind: 'activities', name: 'Wax play' }); + expect(add).toHaveBeenCalledWith({ kind: 'fantasies', name: 'Pirate roleplay' }); + }); + expect(add).not.toHaveBeenCalledWith({ kind: 'activities', name: 'Edging' }); // unchecked + }); + + it('surfaces an AI-suggestion failure calmly (nothing fresh)', async () => { + asOwner(); + installMockBridge({ + questionnairesIntimacyTopics: () => Promise.resolve(view({ activities: [], fantasies: [] })), + questionnairesSuggestIntimacyTopics: () => + Promise.resolve({ + ok: false as const, + reason: 'EMPTY' as const, + message: 'No fresh topics came back — try a different subject.', + }), + }); + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Suggest with AI' })); + expect(await screen.findByText(/No fresh topics came back/)).toBeInTheDocument(); }); }); diff --git a/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.tsx b/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.tsx index c1de18d..fe9b6be 100644 --- a/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.tsx +++ b/apps/desktop/src/renderer/src/settings/IntimacyTopicsControl.tsx @@ -1,11 +1,21 @@ import { useEffect, useState } from 'react'; -import { X } from 'lucide-react'; +import { Sparkles, X } from 'lucide-react'; import type { IntimacyTopicsView } from '@shared/channels'; -import { Banner, Button, Field, Stack, Text, Textarea } from '../design-system/components'; +import { + Banner, + Button, + Field, + Stack, + Text, + Textarea, + TextInput, +} from '../design-system/components'; import { useSessionStore } from '../stores/sessionStore'; import styles from './IntimacyTopicsControl.module.css'; type Kind = 'activities' | 'fantasies'; +/** A reviewable suggestion in the checklist: its kind, whether it's ticked, and the (editable) label. */ +type Pick = { kind: Kind; checked: boolean; text: string }; const EMPTY_VIEW: IntimacyTopicsView = { builtIn: { activities: [], fantasies: [] }, @@ -16,9 +26,11 @@ const EMPTY_VIEW: IntimacyTopicsView = { * Owner-only management of the shared **intimacy topic inventory** (08-questionnaires §16.5a). The Owner * adds/removes custom activities + fantasies (household-wide, vault-stored); the merged inventory (built-in * + custom) seeds AI generation for intimacy questionnaires AND the personal intake. Built-ins are shown - * read-only; only custom additions are removable. 18+ / consensual-adult only — additions are trusted free - * text (the Owner is the full-access role); the boundary is enforced by the generation prompt + the model. - * Add/remove is **owner-only** (`people.manage`); a non-owner admin sees the list read-only. + * read-only; only custom additions are removable. The Owner can also **suggest topics with AI** — name a + * subject (or leave it blank for a varied mix), review the deduped suggestions in a checklist (tick + edit), + * then add the chosen ones. 18+ / consensual-adult only — additions are trusted free text (the Owner is the + * full-access role); the boundary is enforced by the prompt + the model. Add/suggest is **owner-only** + * (`people.manage`); a non-owner admin sees the list read-only. */ export function IntimacyTopicsControl(): JSX.Element { const canManage = useSessionStore((s) => s.can('people.manage')); @@ -28,6 +40,17 @@ export function IntimacyTopicsControl(): JSX.Element { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); + // AI suggestions. + const [subject, setSubject] = useState(''); + const [suggesting, setSuggesting] = useState(false); + const [suggestError, setSuggestError] = useState(null); + const [suggestions, setSuggestions] = useState<{ + activities: string[]; + fantasies: string[]; + } | null>(null); + const [picks, setPicks] = useState>({}); + const [addingSelected, setAddingSelected] = useState(false); + const load = (): void => { void window.selfos ?.questionnairesIntimacyTopics() @@ -64,6 +87,61 @@ export function IntimacyTopicsControl(): JSX.Element { } }; + const onSuggest = async (): Promise => { + if (suggesting) return; + setSuggesting(true); + setSuggestError(null); + setSuggestions(null); + try { + const res = await window.selfos?.questionnairesSuggestIntimacyTopics({ + subject: subject.trim(), + }); + if (!res) { + setSuggestError('Suggestions aren’t available right now.'); + return; + } + if (!res.ok) { + setSuggestError(res.message); + return; + } + setSuggestions(res.suggestions); + const init: Record = {}; + for (const kind of ['activities', 'fantasies'] as const) + for (const text of res.suggestions[kind]) + init[`${kind}:${text}`] = { kind, checked: true, text }; + setPicks(init); + } catch { + setSuggestError('Couldn’t get suggestions. Only the household owner can use this.'); + } finally { + setSuggesting(false); + } + }; + + const selected = Object.values(picks).filter((p) => p.checked && p.text.trim() !== ''); + + const onAddSelected = async (): Promise => { + if (addingSelected || selected.length === 0) return; + setAddingSelected(true); + setError(null); + try { + let latest: IntimacyTopicsView | undefined; + for (const p of selected) { + latest = await window.selfos?.questionnairesAddIntimacyTopic({ + kind: p.kind, + name: p.text.trim(), + }); + } + if (latest) setView(latest); + setSuggestions(null); + setPicks({}); + setSubject(''); + } catch { + setError('Couldn’t add the selected topics.'); + } finally { + setAddingSelected(false); + } + }; + if (!view) return Loading…; return ( @@ -79,6 +157,94 @@ export function IntimacyTopicsControl(): JSX.Element { ) : null} + {canManage ? ( + + + + + Name a theme to explore (or leave it blank for a varied mix). SelfOS proposes fresh + topics — review and edit them before adding. + + + {(props) => ( +