mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-30 13:34:50 +02:00
[eric] onboarding: opt-in usage read (chatgpt memory + claude titles from logged-in card -> prep profile, raw discarded)
This commit is contained in:
@@ -46,6 +46,7 @@ class PrepRequest(BaseModel):
|
||||
scan: Optional[ScanResult] = None
|
||||
picked_apps: List[str] = Field(default_factory=list)
|
||||
identity: List[ProviderIdentity] = Field(default_factory=list)
|
||||
usage_summary: str = ""
|
||||
|
||||
|
||||
class PrepResponse(BaseModel):
|
||||
|
||||
@@ -26,8 +26,9 @@ P_SYSTEM = (
|
||||
"organize local files, browse the web in a real browser, build small apps, and run agents in parallel. "
|
||||
"Given facts about the user's machine and the apps they picked, respond with STRICT JSON only: "
|
||||
'{"greeting": string, "starters": [{"title": string, "prompt": string}], "app_title": string, "app_prompt": string}. '
|
||||
"First, silently infer a short profile of this user from the facts (developer or designer or student, work or "
|
||||
"personal, from apps, folders, plan tier, and email domain); tune every task to that profile and do not output it. "
|
||||
"First, silently infer a short profile of this user. If usage_summary is present it is the STRONGEST signal (it is "
|
||||
"what they actually ask their AI about and facts their AI remembers about them); weight it above everything else, "
|
||||
"then apps, folders, plan tier, email domain. Tune every task and the personal app to that profile; do not output the profile. "
|
||||
"Exactly 4 starters. Each title is 2-5 words. Each prompt is a concrete, safe, immediately runnable task "
|
||||
"referencing the user's real folders, file counts, or picked apps when possible; never invent facts, never "
|
||||
"propose deleting anything without review. The FIRST starter must be an audit sized for parallel sub-work "
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowRight } from 'lucide-react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { updateSettingsPatch } from '@/shared/state/settingsSlice';
|
||||
import { setFlowActive } from '@/shared/state/onboardingV3Slice';
|
||||
import { selectSubscriptionConnections } from '@/shared/state/subscriptionsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { useOnboardingV3Pipeline } from './useOnboardingV3Pipeline';
|
||||
@@ -115,15 +116,18 @@ const OnboardingV3Root: React.FC = () => {
|
||||
const pipeline = useOnboardingV3Pipeline();
|
||||
const [beat, setBeat] = useState<Beat>('welcome');
|
||||
const [scanConsent, setScanConsent] = useState(true);
|
||||
const [usageConsent, setUsageConsent] = useState(true);
|
||||
const [picks, setPicks] = useState<string[]>([]);
|
||||
const connectedProvider = useAppSelector((s) => selectSubscriptionConnections(s).find((cx) => cx.isActive !== false)?.provider ?? null);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
|
||||
const { kickIdentity, kickScan, kickPrep, finish } = pipeline;
|
||||
const { kickIdentity, kickScan, kickUsageRead, kickPrep, finish } = pipeline;
|
||||
|
||||
const onConnected = useCallback(() => {
|
||||
kickIdentity();
|
||||
kickScan(scanConsent);
|
||||
}, [kickIdentity, kickScan, scanConsent]);
|
||||
if (connectedProvider) kickUsageRead(connectedProvider, usageConsent);
|
||||
}, [kickIdentity, kickScan, kickUsageRead, scanConsent, usageConsent, connectedProvider]);
|
||||
|
||||
const leaveConnect = useCallback(() => {
|
||||
kickScan(scanConsent);
|
||||
@@ -190,6 +194,8 @@ const OnboardingV3Root: React.FC = () => {
|
||||
identity={pipeline.identity}
|
||||
scanConsent={scanConsent}
|
||||
setScanConsent={setScanConsent}
|
||||
usageConsent={usageConsent}
|
||||
setUsageConsent={setUsageConsent}
|
||||
onConnected={onConnected}
|
||||
onNext={leaveConnect}
|
||||
onBack={() => setBeat('newos')}
|
||||
|
||||
@@ -19,10 +19,12 @@ const BeatConnect: React.FC<{
|
||||
identity: ProviderIdentity[];
|
||||
scanConsent: boolean;
|
||||
setScanConsent: (v: boolean) => void;
|
||||
usageConsent: boolean;
|
||||
setUsageConsent: (v: boolean) => void;
|
||||
onConnected: () => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, identity, scanConsent, setScanConsent, onConnected, onNext, onBack }) => {
|
||||
}> = ({ c, identity, scanConsent, setScanConsent, usageConsent, setUsageConsent, onConnected, onNext, onBack }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const connected = useAppSelector((s) => hasModelConnected(s));
|
||||
const freeTrial = useAppSelector((s) => hasFreeTrialActive(s));
|
||||
@@ -149,6 +151,21 @@ const BeatConnect: React.FC<{
|
||||
</button>
|
||||
<span>Take a quick local look around to personalize my setup. Nothing leaves this Mac.</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 9, cursor: 'pointer', fontSize: '0.82rem', color: c.text.tertiary, lineHeight: 1.5 }}>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); setUsageConsent(!usageConsent); }}
|
||||
style={{
|
||||
width: 16, height: 16, marginTop: 2, borderRadius: 5, padding: 0, cursor: 'pointer', flexShrink: 0,
|
||||
border: `1.5px solid ${usageConsent ? c.accent.primary : c.border.strong}`,
|
||||
background: usageConsent ? c.accent.primary : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 120ms ease, border-color 120ms ease',
|
||||
}}
|
||||
>
|
||||
{usageConsent && <Check size={11} color="#fff" strokeWidth={3.2} />}
|
||||
</button>
|
||||
<span>Learn what I work on from my chats (read once, never stored).</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 18, marginTop: 2 }}>
|
||||
<button onClick={() => setShowKeys(!showKeys)} style={{ border: 'none', background: 'transparent', padding: 0, color: c.text.ghost, fontSize: '0.82rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline' }}>
|
||||
Use an API key instead
|
||||
|
||||
@@ -50,12 +50,12 @@ export async function runScan(): Promise<ScanResult | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPrep(scan: ScanResult | null, pickedApps: string[], identity: ProviderIdentity[]): Promise<PrepResponse | null> {
|
||||
export async function runPrep(scan: ScanResult | null, pickedApps: string[], identity: ProviderIdentity[], usageSummary: string): Promise<PrepResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/onboarding/prep`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scan, picked_apps: pickedApps, identity }),
|
||||
body: JSON.stringify({ scan, picked_apps: pickedApps, identity, usage_summary: usageSummary }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as PrepResponse;
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
fetchIdentity, runPrep, runScan, summarizeScan,
|
||||
type PrepResponse, type ProviderIdentity, type ScanResult,
|
||||
} from './onboardingV3Api';
|
||||
import { USAGE_READ_JS, summarizeUsage, type ProviderUsage, type UsageProvider } from '@/shared/providerUsage';
|
||||
import { findWebviewByDomain } from '@/shared/browserRegistry';
|
||||
|
||||
// The curtain machinery: scan kicks off during the OAuth wait, prep during the theme beat, and the moment prep resolves the audit AND the app build launch as REAL background agents, so the curtain lifts on work already in motion. Every stage fails soft; the flow never blocks on any of it.
|
||||
export function useOnboardingV3Pipeline() {
|
||||
@@ -21,6 +23,8 @@ export function useOnboardingV3Pipeline() {
|
||||
const scanRef = useRef<Promise<ScanResult | null> | null>(null);
|
||||
const prepRef = useRef<Promise<PrepResponse | null> | null>(null);
|
||||
const scanResultRef = useRef<ScanResult | null>(null);
|
||||
const usageSummaryRef = useRef<string>('');
|
||||
const usageReadRef = useRef<Promise<void> | null>(null);
|
||||
const connected = useAppSelector((s) => hasModelConnected(s));
|
||||
const model = useAppSelector((s) => s.settings.data.default_model);
|
||||
const launchCtxRef = useRef({ connected: false, model: 'sonnet' });
|
||||
@@ -31,6 +35,22 @@ export function useOnboardingV3Pipeline() {
|
||||
fetchIdentity().then((ids) => { identityRef.current = ids; setIdentity(ids); }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Read what the user works on from an already-open, logged-in provider card (chatgpt.com / claude.ai) via the proven findWebviewByDomain + executeJavaScript path. Fail-open: no card, not logged in, or off-Electron => empty summary, prep falls back to scan + identity.
|
||||
const kickUsageRead = useCallback((provider: string, consented: boolean) => {
|
||||
if (usageReadRef.current || !consented) return;
|
||||
const key: UsageProvider | null = provider === 'codex' ? 'codex' : provider === 'claude' ? 'claude' : null;
|
||||
if (!key) return;
|
||||
usageReadRef.current = (async () => {
|
||||
try {
|
||||
const domain = key === 'codex' ? 'chatgpt.com' : 'claude.ai';
|
||||
const wv = findWebviewByDomain(domain);
|
||||
if (!wv || typeof wv.executeJavaScript !== 'function') return;
|
||||
const raw = (await wv.executeJavaScript(USAGE_READ_JS[key])) as ProviderUsage | null;
|
||||
usageSummaryRef.current = summarizeUsage(raw);
|
||||
} catch { /* fail-open */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const kickScan = useCallback((consented: boolean) => {
|
||||
if (scanRef.current) return;
|
||||
scanRef.current = consented
|
||||
@@ -56,7 +76,10 @@ export function useOnboardingV3Pipeline() {
|
||||
const kickPrep = useCallback((pickedApps: string[]) => {
|
||||
if (prepRef.current) return;
|
||||
const scanPromise = scanRef.current ?? Promise.resolve(null);
|
||||
prepRef.current = scanPromise.then((scan) => runPrep(scan, pickedApps, identityRef.current)).catch(() => null);
|
||||
const usagePromise = usageReadRef.current ?? Promise.resolve();
|
||||
prepRef.current = Promise.all([scanPromise, usagePromise])
|
||||
.then(([scan]) => runPrep(scan, pickedApps, identityRef.current, usageSummaryRef.current))
|
||||
.catch(() => null);
|
||||
// Launch the prepped work MID-FLOW (theme/card beats cover the latency): audit + app build, gated to a real connected model so the fragile free trial never carries it.
|
||||
void prepRef.current.then((prep) => {
|
||||
if (launchedRef.current || !prep || !prep.greeting || !launchCtxRef.current.connected) return;
|
||||
@@ -94,5 +117,5 @@ export function useOnboardingV3Pipeline() {
|
||||
dispatch(setFlowActive(false));
|
||||
}, [dispatch, accent, gradient, mode]);
|
||||
|
||||
return { identity, kickIdentity, kickScan, kickPrep, finish };
|
||||
return { identity, kickIdentity, kickScan, kickUsageRead, kickPrep, finish };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Reads what the user actually uses their AI for, from a logged-in provider website inside a browser-card webview: ChatGPT Memory + recent titles, or Claude conversation titles. Same-origin fetch using the site's own session (the proven extension technique). The RAW result never leaves the renderer; only the derived summary goes to prep, and even that is dropped after.
|
||||
|
||||
export type UsageProvider = 'codex' | 'claude';
|
||||
|
||||
export interface ProviderUsage {
|
||||
ok: boolean;
|
||||
titles: string[];
|
||||
memories: string[];
|
||||
}
|
||||
|
||||
// Runs in the WEBVIEW page context (chatgpt.com / claude.ai), so it inherits the live session. Kept as a string because it is injected via webview.executeJavaScript.
|
||||
export const USAGE_READ_JS: Record<UsageProvider, string> = {
|
||||
codex: `(async () => {
|
||||
try {
|
||||
const sess = await fetch('/api/auth/session', {credentials:'include'}).then(r=>r.json());
|
||||
if (!sess || !sess.accessToken) return {ok:false, titles:[], memories:[]};
|
||||
const H = {headers:{Authorization:'Bearer '+sess.accessToken, accept:'application/json'}, credentials:'include'};
|
||||
const conv = await fetch('/backend-api/conversations?offset=0&limit=25&order=updated', H).then(r=>r.ok?r.json():null).catch(()=>null);
|
||||
const mem = await fetch('/backend-api/memories?include_memory_entries=true', H).then(r=>r.ok?r.json():null).catch(()=>null);
|
||||
return {
|
||||
ok: true,
|
||||
titles: ((conv && conv.items) || []).map(c=>c.title).filter(Boolean).slice(0,25),
|
||||
memories: ((mem && mem.memories) || []).map(m=>m.content).filter(Boolean).slice(0,25),
|
||||
};
|
||||
} catch (e) { return {ok:false, titles:[], memories:[]}; }
|
||||
})()`,
|
||||
claude: `(async () => {
|
||||
try {
|
||||
const orgs = await fetch('/api/organizations', {credentials:'include', headers:{accept:'application/json'}}).then(r=>r.ok?r.json():null).catch(()=>null);
|
||||
if (!Array.isArray(orgs) || !orgs.length) return {ok:false, titles:[], memories:[]};
|
||||
const org = orgs[0].uuid;
|
||||
const convs = await fetch('/api/organizations/'+org+'/chat_conversations?limit=30', {credentials:'include', headers:{accept:'application/json'}}).then(r=>r.ok?r.json():null).catch(()=>null);
|
||||
return {ok:true, titles:(Array.isArray(convs)?convs:[]).map(c=>c.name).filter(Boolean).slice(0,30), memories:[]};
|
||||
} catch (e) { return {ok:false, titles:[], memories:[]}; }
|
||||
})()`,
|
||||
};
|
||||
|
||||
export const USAGE_ORIGIN: Record<UsageProvider, string> = {
|
||||
codex: 'https://chatgpt.com/',
|
||||
claude: 'https://claude.ai/',
|
||||
};
|
||||
|
||||
// Turn the raw read into a compact block for the prep prompt. Cap hard so we never ship a wall of PII; titles + memory facts are the whole signal.
|
||||
export function summarizeUsage(u: ProviderUsage | null): string {
|
||||
if (!u || !u.ok) return '';
|
||||
const parts: string[] = [];
|
||||
if (u.memories.length > 0) parts.push('Facts their AI remembers about them: ' + u.memories.join('; '));
|
||||
if (u.titles.length > 0) parts.push('Recent things they asked their AI: ' + u.titles.join('; '));
|
||||
return parts.join('\n').slice(0, 1500);
|
||||
}
|
||||
Reference in New Issue
Block a user