diff --git a/CLAUDE.md b/CLAUDE.md index bbb74fc..41720f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -389,6 +389,31 @@ placing anything. Specifically: A running log of durable decisions and feedback captured into the project config. Newest first. +- 2026-06-26 — **Build (owner AI intimacy-topic suggester — SPEC 08 §16.5a AI-assist; on `feat/ai-intimacy-topics`, + PR pending).** The last item of the 2026-06-26 batch. The owner-only Intimacy Topics settings control gains a + **"Suggest with AI"** affordance. **All 3 product/UX forks asked first (user-chosen):** lives **in the existing + owner control** (not a separate panel); driven by a **subject box that falls back to a general varied batch when + blank** ("Both"); suggestions are added via a **pick-and-edit checklist** ("tick + edit, then Add selected"). Core + `@selfos/core/intimacy/suggestService.ts` (`suggestIntimacyTopics`) — one metered **`intimacy.suggestTopics`** pass + (budget-gated, meter-BEFORE-parse, tolerant `extractJsonObject` + honest failures, `extendedThinking:false`) that + proposes consensual-adult **activity + fantasy** topics and **dedupes** them case-insensitively against the merged + inventory (built-in + custom; an all-dupes result is an honest EMPTY). **Persists nothing** — the owner reviews + + the existing `addCustomIntimacyTopic` path commits the chosen ones. The consensual-adult boundary lives in the + prompt + the model (never a keyword filter); the Owner is the full-access role so the seam is gated `people.manage` + (owner-only), and **AI-off is a calm state** (no dead button). IPC `questionnaires:suggestIntimacyTopics` through + the full seam + the offline fake-Claude branch (returns a set incl. an existing built-in, so the dedupe is + exercised offline). Renderer: the control gains a subject `Textarea` + "Suggest with AI" → a checklist of + checkbox+editable rows → "Add selected (N)". Additive schema (`IntimacyTopicSuggestResult` + the usage type) — no + migration. Gate green: typecheck (all), lint, **938 core + 854 desktop** unit (4 core suggester [dedup/meter/ground, + no-subject, NO_KEY, EMPTY] + 6 control RTL [suggest/pick/edit/add, EMPTY surface, non-owner hidden] + a coreBridge + test [owner deduped, member denied, AI-off calm]), **E2E** (the Settings flow: Suggest → checklist → a built-in + deduped out → uncheck one → Add selected → persisted across both kinds). Visual QA (real Electron screenshot): the + subject box + the pick-and-edit checklist + "Add selected (3)" read clean + cohesive with Settings. Synced spec 08 + §16.5a. **Lesson: a "suggest then add" feature is cheapest as an EPHEMERAL pass (persist nothing) layered on the + existing add path — the suggester only computes deduped candidates, the owner's checklist drives the existing + per-topic add, so there's no new persistence, no migration, and the dedupe is the one real guarantee (the prompt's + avoid-list just nudges the model).** + - 2026-06-26 — **Build (guided-sessions expansion — fuller therapy/coaching + a Family group + catalog search; on `feat/guided-sessions-expansion`, PR pending).** Additive content + one small UI feature (an extension of spec 16/48, no new machinery, no schema/IPC). **New `family` group "Family & relationships"** (12 diff --git a/apps/desktop/e2e/launch.spec.ts b/apps/desktop/e2e/launch.spec.ts index e276a84..e6d9dff 100644 --- a/apps/desktop/e2e/launch.spec.ts +++ b/apps/desktop/e2e/launch.spec.ts @@ -4469,7 +4469,7 @@ test('authoring (§16.4): AI draft fills the empty title; Save→Send is a two-s } }); -test('intimacy topics (§16.5a): the owner manages custom topics in Settings + an inline builder add, persisted', async () => { +test('intimacy topics (§16.5a): the owner manages custom topics in Settings + AI suggest + an inline builder add, persisted', async () => { const { userData, vault } = await seedReadyVault({ 'ai.enabled': true }); await createNodeSecretStore(userData, passthrough).set('anthropic.apiKey', 'sk-ant-e2e'); const fs = createNodeFileSystem(vault); @@ -4494,6 +4494,25 @@ test('intimacy topics (§16.5a): the owner manages custom topics in Settings + a .poll(async () => (await readCustomIntimacyTopics(fs)).activities) .toContain('Sploshing'); + // Suggest with AI: the offline fake proposes a set that INCLUDES an existing built-in ('Sensual + // massage') — which is deduped out of the checklist. Uncheck one fresh suggestion, add the rest. + await w.getByRole('button', { name: 'Suggest with AI' }).click(); + await expect(w.getByLabel('Include Mutual edging')).toBeVisible(); + await expect(w.getByLabel('Include Temperature contrast play')).toBeVisible(); + await expect(w.getByLabel('Include Sensual massage')).toHaveCount(0); // a built-in → deduped + await w.getByLabel('Include Temperature contrast play').uncheck(); + await w.getByRole('button', { name: /Add selected/ }).click(); + // The picked suggestions persist (across both kinds); the unchecked one does not. + await expect + .poll(async () => (await readCustomIntimacyTopics(fs)).activities) + .toContain('Mutual edging'); + await expect + .poll(async () => (await readCustomIntimacyTopics(fs)).fantasies) + .toContain('Rivals-to-lovers roleplay'); + expect((await readCustomIntimacyTopics(fs)).activities).not.toContain( + 'Temperature contrast play', + ); + // The inline builder add (owner) writes to the SAME shared list: author an intimacy/unfiltered // questionnaire and add a fantasy from the AI panel. await w.getByRole('link', { name: 'Questionnaires' }).click(); 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) => ( +