diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 06e080bc9..0d9febb40 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,11 +16,12 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/index.tsx` and `attack/[id].tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` and `(practice)/soon/[tool].tsx` stand in for tools not in the app yet. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. -- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ask-config.ts` reads the Ask model from `user_settings.feature_configs`, `ask-context.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. +- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. +- Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - One-off mode is session-only and never written, as on the website. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5cb31b52e..c1ef574cc 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -16,14 +16,18 @@ "aes-js": "^3.1.2", "date-fns": "^3.6.0", "expo": "~57.0.27", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", + "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", + "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", + "expo-sharing": "~57.0.22", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 8a36f209e..9628be503 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -28,10 +28,17 @@ * fetch), answers the ai function with a fixed answer with citations, and * answers chat and message writes locally. The Library screens read the * account's real sources and write nothing. + * + * Generate and Attack screenshots never call a model and never save: the + * page answers the ai function with a fixed case or attack, holds it open + * for the "generating" shot, and answers case, attack and source writes + * locally. The PDF export's HTML (the web export cannot run expo-print) is + * rendered in a page of its own, and the "Copy as text" output is written + * next to the screenshots. */ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test'; import { createReadStream, existsSync, statSync } from 'node:fs'; -import { mkdir } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:http'; import { extname, join, normalize } from 'node:path'; @@ -199,6 +206,173 @@ async function fakeAsk(page: Page, hold: boolean) { } } +// A made-up case in GenerateOutput's shape, after mock 03: three arguments for a Standard case. +const FAKE_CASE = { + caseStructure: { + thesis: 'City centres are shared public space, and private cars take the most of it while costing everyone else their health.', + framework: 'City centres are shared public space. The motion asks who gets to use that space, and at what cost to everyone else\'s health.', + keyDefinitions: ['City centre: the area inside a city\'s inner ring road', 'Private cars: privately owned passenger vehicles, with exemptions for disabled drivers'], + burdenOfProof: 'We must show that removing private cars improves health and access for most people who use the centre.', + }, + contentions: [ + { + id: '1', + claim: 'Clean air is a public health duty', + reasoning: 'Traffic is the largest source of roadside pollution, and the people who breathe it most are pedestrians, cyclists and residents, not drivers.', + evidence: 'Low-traffic zones cut roadside NO₂ within their first year, according to the Clean Streets Review.', + needsSource: false, + impact: 'Fewer asthma admissions among children who live and study in the centre.', + }, + { + id: '2', + claim: 'Streets back for people', + reasoning: 'Parked and moving cars take most of a centre\'s street space while carrying a minority of the people in it.', + evidence: 'The Street Space Audit found cars use most of the road area; the Footfall Study found shops gain visits when streets close to traffic.', + needsSource: false, + impact: 'Wider pavements, more seating and safer crossings for everyone.', + }, + { + id: '3', + claim: 'Transit works when it isn\'t stuck in traffic', + reasoning: 'Buses lose most of their delay to congestion caused by private cars.', + evidence: 'Bus journeys became quicker and more reliable after the centre closed to through traffic, per the Clean Streets Review.', + needsSource: false, + impact: 'Faster, more reliable buses make the ban fair to people who cannot walk or cycle.', + }, + ], + predictedOpponentCase: { + likelyArguments: ['Shift workers and carers need cars at hours when transit is thin', 'Businesses lose customers who drive in'], + strategicWeaknesses: ['Depends on exemptions working in practice'], + keyClashes: ['Access for the few against health for the many', 'Shops and footfall'], + }, + counters: [ + { targetArgument: 'Shift workers need cars', counterpoint: 'Exemptions and night buses cover them.', rebuttal: 'Every real ban exempts carers and shift workers, and transit follows demand.' }, + ], + metadata: { + side: 'pro', + format: 'custom', + generatedAt: '2026-10-09T16:43:00.000Z', + model: 'test-model', + provider: 'openrouter', + durationMs: 41200, + sourceUsage: [ + { key: 'https://example.org/clean-streets', title: 'Clean Streets Review', origin: 'https://example.org/clean-streets', importance: 3, strength: 3, probability: 0.9, judgment: 'used' }, + { key: 'https://example.org/street-space', title: 'Street Space Audit', origin: 'https://example.org/street-space', importance: 2, strength: 2, probability: 0.8, judgment: 'used' }, + { key: 'https://example.org/footfall', title: 'Footfall Study', origin: 'https://example.org/footfall', importance: 2, strength: 2, probability: 0.7, judgment: 'used' }, + ], + }, +}; +const FAKE_SOURCES = [ + { title: 'Clean Streets Review', url: 'https://example.org/clean-streets', content: 'Made up for screenshots.', qualityLabel: 'strong', qualityScore: 82 }, + { title: 'Footfall Study', url: 'https://example.org/footfall', content: 'Made up for screenshots.', qualityLabel: 'moderate', qualityScore: 64 }, +]; +// A made-up attack in AttackOutput's shape. +const FAKE_ATTACK = { + overallAssessment: { + score: 64, + summary: 'A clear case with strong health evidence, but it leans on exemptions it never defines.', + strengthAreas: ['Concrete health impact', 'Good use of the street space argument'], + }, + weaknesses: [ + { id: 'w1', severity: 'critical', type: 'evidential', description: 'Exemptions are assumed, not shown', whyWeak: 'The case says carers and shift workers are exempt but never says how.', howOpponentAttacks: 'Who checks the exemptions, and what happens to the nurse at 5 a.m.?', improvedVersion: 'Name a real scheme and say how its exemptions work.', affectedQuote: 'Every real ban exempts carers' }, + { id: 'w2', severity: 'major', type: 'logical', description: 'Footfall gains may not transfer', whyWeak: 'One study of one city does not prove shops gain everywhere.', howOpponentAttacks: 'Your study is one city with a metro.', improvedVersion: 'Compare two centres of different sizes.' }, + { id: 'w3', severity: 'minor', type: 'structural', description: 'Impact of the third argument is thin', whyWeak: 'It restates the claim.', howOpponentAttacks: 'So what if buses are faster?', improvedVersion: 'Tie faster buses to who can reach jobs.' }, + ], + fallaciesDetected: [{ name: 'Hasty generalisation', location: 'Argument 2', explanation: 'One city stands in for all cities.' }], + crossExQuestions: [{ question: 'How many people would need an exemption?', purpose: 'Test the size of the carve-out', expectedTrap: 'A large number undercuts the health benefit' }], + weighingAnalysis: { currentWeighing: 'Health outweighs convenience.', missingComparisons: ['Cost to low-income drivers'], improvementSuggestions: ['Weigh by number of people affected'] }, +}; +const FAKE_ROW = (table: string) => ({ + id: table === 'sources' ? '00000000-0000-4000-8000-0000000050c0' : '00000000-0000-4000-8000-00000000ca5e', + title: 'fake', + content: '', + type: 'url', + origin: null, + metadata: null, + created_at: new Date().toISOString(), +}); + +/** Fakes Generate's and Attack's AI calls and saves for this page (see the header). `hold` keeps Generate waiting. */ +async function fakeResults(page: Page, hold: boolean) { + await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { + const feature = (route.request().postDataJSON() as { feature?: string } | null)?.feature; + if (feature === 'generate' && hold) return; // Never answered: the page shows its progress state. + if (feature === 'generate') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES } }); + if (feature === 'attack') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_ATTACK, discoveredSources: [] } }); + return route.abort(); + }); + for (const table of ['cases', 'attacks', 'sources', 'source_folders']) { + await page.route(`${REST}/${table}*`, (route) => + route.request().method() === 'POST' ? route.fulfill({ status: 201, json: FAKE_ROW(table) }) : route.continue(), + ); + } +} + +/** Prepare, Generate, the case document, Export and Attack, all from fixed outputs (see the header). */ +async function shootResultScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const open = async (hold: boolean) => { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} results`); + page.on('dialog', (dialog) => void dialog.dismiss()); + await pinSettings(page, language); + await fakeResults(page, hold); + return page; + }; + + const waiting = await open(true); + await waiting.goto(`${ORIGIN}/prepare`); + await waiting.getByTestId('prepare-tool-generate').waitFor({ timeout: 30_000 }); + await settle(waiting); + await shoot(waiting, `${prefix}-prepare`); + await waiting.getByTestId('prepare-tool-generate').click(); + await waiting.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await waiting.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); + await shoot(waiting, `${prefix}-generate-form`); + await waiting.getByRole('button', { name: /^(Generate Debate Case|Vitaeset Generálása|Generar Caso de Debate)$/ }).click(); + await waiting.getByTestId('tool-progress').waitFor({ timeout: 15_000 }); + await shoot(waiting, `${prefix}-generating`); + await waiting.close(); + + const page = await open(false); + await page.goto(`${ORIGIN}/generate?from=prepare`); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); + await page.getByRole('button', { name: /^(Generate Debate Case|Vitaeset Generálása|Generar Caso de Debate)$/ }).click(); + await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-case`); + // Close the open argument, so every argument shows collapsed. + await page.getByTestId('document-item').first().click(); + await shoot(page, `${prefix}-case-collapsed`); + await page.getByTestId('document-item').first().click(); + + await page.getByTestId('export-button').click(); + await page.getByTestId('export-menu').waitFor(); + await shoot(page, `${prefix}-export-menu`); + await page.getByTestId('export-menu').getByRole('menuitem').first().click(); + await page.getByTestId('export-button').click(); + await page.getByTestId('export-menu').getByRole('menuitem').nth(1).click(); + const exported = await page.evaluate(() => (window as unknown as { __debatelabExport?: { html?: string; fileName?: string; copied?: string } }).__debatelabExport ?? {}); + if (!exported.html || !exported.copied) throw new Error('Export did not hand over the PDF HTML or the copied text'); + await writeFile(join(OUT, `${prefix}-case-copy.txt`), exported.copied); + const pdf = await context.newPage(); + await pdf.setViewportSize({ width: 794, height: 1123 }); + await pdf.setContent(exported.html); + await pdf.screenshot({ path: join(OUT, `${prefix}-case-pdf-html.png`), fullPage: true }); + await pdf.close(); + console.log(`${prefix}: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`); + + await page.getByTestId('attack-this-case').click(); + await page.getByTestId('attack-case-text').waitFor({ timeout: 15_000 }); + await page.waitForFunction(() => (document.querySelector('[data-testid="attack-case-text"]') as HTMLTextAreaElement | null)?.value.startsWith('THESIS:')); + await shoot(page, `${prefix}-attack-form`); + await page.getByRole('button', { name: /^(Find Weaknesses|Gyengeségek Keresése|Encontrar Debilidades)$/ }).click(); + await page.getByTestId('attack-document').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-attack-result`); + await page.close(); +} + async function shootLibraryScreens(page: Page, prefix: string) { await page.goto(`${ORIGIN}/library`); await page.getByTestId('library-add').waitFor({ timeout: 30_000 }); @@ -402,6 +576,8 @@ async function main() { shots += 1; await shootAskScreens(signedIn, prefix, language, problems); shots += 3; + await shootResultScreens(signedIn, prefix, language, problems); + shots += 9; await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index ff1759905..d96f3b6bc 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -8,9 +8,11 @@ import { MainActionButton, TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; +import { PREPARE_TOOLS } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; +import { WorkRow, workHref } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { @@ -18,36 +20,13 @@ import { fetchRecentWork, type DebateCounts, type DebateItem, - type DebateItemKind, type DebateSide, } from '@/lib/debates'; -import { formatWhen } from '@/lib/dates'; import { mainActionGradient, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; const RECENT_LIMIT = 3; -// The Prepare tools on the website's Current Debate page. Each opens the Prepare tab until its screen lands. -const PREPARE_TOOLS: { key: string; icon: IconName }[] = [ - { key: 'generate', icon: 'sparkles' }, - { key: 'brainstorm', icon: 'bulb' }, - { key: 'factoids', icon: 'book' }, - { key: 'flowchart', icon: 'branch' }, -]; - -const KIND_ICONS: Record = { - case: 'sparkles', - attack: 'swords', - brainstorm: 'bulb', - factoids: 'book', - chat: 'chat', - flowchart: 'branch', - crossfire: 'target', - sparring: 'swords', -}; - -const PRACTICE_KINDS = new Set(['attack', 'crossfire', 'sparring']); - /** Home: the Current Debate, as in mock 01. */ export default function DebateScreen() { const { t } = useTranslation(); @@ -222,7 +201,15 @@ function DebateDetails({ debateId }: { debateId: string }) { {PREPARE_TOOLS.map((tool) => ( - open('/prepare')} /> + + tool.key === 'generate' + ? router.push({ pathname: '/generate', params: { from: 'debate' } }) + : router.push({ pathname: '/coming/[tool]', params: { tool: tool.key } })} + /> ))} @@ -247,12 +234,7 @@ function DebateDetails({ debateId }: { debateId: string }) { ) : ( {items.map((item, index) => ( - open(PRACTICE_KINDS.has(item.kind) ? '/practice' : '/prepare')} - /> + router.navigate(workHref(item, 'debate'))} /> ))} )} @@ -319,32 +301,6 @@ function ToolTile({ icon, label, onPress }: { icon: IconName; label: string; onP ); } -function RecentRow({ item, first, onPress }: { item: DebateItem; first: boolean; onPress: () => void }) { - const { t } = useTranslation(); - const { colors } = useTheme(); - return ( - [ - styles.row, - !first && { borderTopWidth: 1, borderTopColor: colors.line }, - pressed && styles.pressed, - ]}> - - - - {item.title || t(`debate.kinds.${item.kind}`)} - - - {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} - - - - - ); -} - const styles = StyleSheet.create({ loading: { marginTop: 32 }, body: { fontSize: 16, lineHeight: 22 }, @@ -412,8 +368,4 @@ const styles = StyleSheet.create({ }, tileLabel: { flex: 1, fontSize: 15.5 }, list: { padding: 0, gap: 0 }, - row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, - rowText: { flex: 1, gap: 2 }, - rowTitle: { fontSize: 16 }, - rowMeta: { fontSize: 14 }, }); diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 1ea88571b..0cd759c39 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -10,7 +10,7 @@ import { Screen } from '@/components/screen'; import { Text } from '@/components/text'; import { useTheme } from '@/theme/use-theme'; -/** The Practice tools. Ask opens full screen; the others say they are on their way. */ +/** The Practice tools. Attack and Ask open full screen; the others say they are on their way. */ export default function PracticeScreen() { const { t } = useTranslation(); const router = useRouter(); @@ -23,7 +23,13 @@ export default function PracticeScreen() { (tool.key === 'ask' ? router.push('/ask') : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } }))} + onPress={() => + tool.key === 'ask' + ? router.push('/ask') + : tool.key === 'attack' + ? router.push({ pathname: '/attack', params: { from: 'practice' } }) + : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } })} + testID={`practice-tool-${tool.key}`} style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> diff --git a/apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx b/apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx new file mode 100644 index 000000000..f89f641d2 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx @@ -0,0 +1,13 @@ +import { useLocalSearchParams } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { PREPARE_TOOLS } from '@/components/prepare-tools'; + +/** A Prepare tool that is not in the app yet. */ +export default function PrepareToolScreen() { + const { t } = useTranslation(); + const { tool } = useLocalSearchParams<{ tool: string }>(); + const entry = PREPARE_TOOLS.find((item) => item.key === tool); + return ; +} diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index f85db33d5..b3da5f601 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -1,5 +1,134 @@ -import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; -export default function Screen() { - return ; +import { TextButton } from '@/components/buttons'; +import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; +import { PREPARE_TOOLS, type PrepareTool } from '@/components/prepare-tools'; +import { Screen } from '@/components/screen'; +import { ScreenTitle } from '@/components/screen-title'; +import { Text } from '@/components/text'; +import { WorkRow, workHref } from '@/components/work-row'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { fetchPrepareWork, type DebateItem } from '@/lib/debates'; +import { useTheme } from '@/theme/use-theme'; + +const SAVED_LIMIT = 30; + +/** The Prepare tools, then the Current Debate's saved Prepare results, newest first. */ +export default function PrepareScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { current, oneOff } = useDebates(); + const debateId = current?.id ?? null; + // Each load belongs to the debate it was read for, so another debate's results never show, or open, under this one. + const [loaded, setLoaded] = useState<{ debateId: string; items: DebateItem[] | null; failed: boolean } | null>(null); + const items = loaded?.debateId === debateId ? loaded.items : null; + const failed = loaded?.debateId === debateId && loaded.failed; + + const openTool = (tool: PrepareTool) => + tool === 'generate' ? router.push({ pathname: '/generate', params: { from: 'prepare' } }) : router.push({ pathname: '/coming/[tool]', params: { tool } }); + + // Every load, from focus or Retry, takes a number; only the newest may write, so a late Retry for another debate never replaces this one's. + const loads = useRef(0); + const load = useCallback(async () => { + if (!userId || !debateId) return; + const forDebate = debateId; + const request = ++loads.current; + const isCurrent = () => loads.current === request; + try { + const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT); + if (!isCurrent()) return; + setLoaded({ debateId: forDebate, items: next, failed: false }); + } catch (cause) { + if (!isCurrent()) return; + console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause); + setLoaded({ debateId: forDebate, items: null, failed: true }); + } + }, [userId, debateId]); + + // Reload on focus, so a case generated a moment ago, or on the website, shows up. + useFocusEffect( + useCallback(() => { + void load(); + return () => { + loads.current += 1; + }; + }, [load]), + ); + + return ( + + + + {PREPARE_TOOLS.map((tool, index) => ( + openTool(tool.key)} + testID={`prepare-tool-${tool.key}`} + style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + + {t(`nav.${tool.key}`)} + + + {t(`navDesc.${tool.key}`)} + + + + + ))} + + + + {t('mobile.prepare.saved')} + + {oneOff || !debateId ? ( + + {oneOff ? t('debate.oneOffStrip') : t('debate.noCurrentStrip')} + + ) : failed ? ( + + + {t('mobile.debate.loadFailed')} + + void load()} /> + + ) : items === null ? ( + + ) : items.length === 0 ? ( + + + {t('debate.noWork')} + + + ) : ( + + {items.map((item, index) => ( + router.push(workHref(item, 'prepare'))} /> + ))} + + )} + + ); } + +const styles = StyleSheet.create({ + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + rowText: { flex: 1, gap: 2 }, + rowTitle: { fontSize: 16 }, + rowMeta: { fontSize: 14 }, + pressed: { opacity: 0.7 }, + section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 8, marginHorizontal: 2 }, + body: { fontSize: 15.5, lineHeight: 21 }, + loading: { marginTop: 16 }, +}); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 6691de4f6..289e641ee 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -81,6 +81,11 @@ function Root() { {/* Full screen above the tabs, as in mock 07. */} + {/* Generate, Attack and their documents, full screen above the tabs, as in mock 03. */} + + + + diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 3ba8333a9..0d432ced5 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -1,5 +1,5 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; -import { useRouter } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; @@ -12,22 +12,14 @@ import { Chip } from '@/components/chip'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { Text } from '@/components/text'; -import { - askOnce, - AskError, - canStream, - isAbortError, - isFinalAskError, - isMessageWithinBudget, - streamAsk, - type AskErrorKind, - type AskOptions, -} from '@/lib/ask'; -import { fetchAskConfig, type AskConfig } from '@/lib/ask-config'; -import { buildAskDebateContext } from '@/lib/ask-context'; +import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; +import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskOptions } from '@/lib/ask'; +import { buildDebateContext } from '@/lib/context-request'; +import { fetchFeatureConfig, type FeatureConfig } from '@/lib/feature-config'; import { useAuth } from '@/lib/auth'; import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; import { useDebates } from '@/lib/debate-context'; +import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { fetchSources, type Source } from '@/lib/sources'; import { radius } from '@/theme/colors'; @@ -47,8 +39,8 @@ const OPTION_CHIPS: { key: keyof AskOptions; label: string; icon: IconName }[] = const THINKING = ['askChat.thinking', 'askChat.researching', 'askChat.analyzing', 'askChat.generatingResponse', 'askChat.almostThere']; -const PLAN_ERRORS = new Set(['trialExpired', 'limitReached', 'modelRestricted']); -const RETRYABLE_ERRORS = new Set(['network', 'timeout', 'failed', 'rateLimit']); +const PLAN_ERRORS = new Set(['trialExpired', 'limitReached', 'modelRestricted']); +const RETRYABLE_ERRORS = new Set(['network', 'timeout', 'failed', 'rateLimit']); let localId = 0; const nextId = () => `local-${Date.now()}-${(localId += 1)}`; @@ -57,6 +49,7 @@ const nextId = () => `local-${Date.now()}-${(localId += 1)}`; export default function AskScreen() { const { t } = useTranslation(); const router = useRouter(); + const { prompt } = useLocalSearchParams<{ prompt?: string }>(); const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session } = useAuth(); @@ -71,7 +64,7 @@ export default function AskScreen() { const [record, setRecordState] = useState(NEW_CHAT); const [options, setOptions] = useState(NO_OPTIONS); const [input, setInput] = useState(''); - const [error, setError] = useState<{ kind: AskErrorKind; question: string } | null>(null); + const [error, setError] = useState<{ kind: AIErrorKind; question: string } | null>(null); const [notice, setNotice] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); const [library, setLibrary] = useState([]); @@ -79,7 +72,7 @@ export default function AskScreen() { const scroll = useRef(null); const requests = useRef(0); const abort = useRef(null); - const config = useRef | null>(null); + const config = useRef | null>(null); // The conversation the saved record belongs to; replacing it drops late writes. const conversation = useRef(0); const recordRef = useRef(record); @@ -93,7 +86,7 @@ export default function AskScreen() { }; const askConfig = () => { - if (!config.current && userId) config.current = fetchAskConfig(userId); + if (!config.current && userId) config.current = fetchFeatureConfig(userId, 'ask'); return config.current!; }; @@ -133,10 +126,9 @@ export default function AskScreen() { // The debate this conversation reads from: a loaded chat keeps its own. const chatDebateId = record.chatId ? record.debateId : activeDebateId; useEffect(() => { - if (!chatDebateId || !userId) { - setLibrary([]); - return; - } + // The previous debate's sources never stand in for this one's while they load. + setLibrary([]); + if (!chatDebateId || !userId) return; let active = true; fetchSources(userId, chatDebateId) .then((next) => { @@ -194,7 +186,8 @@ export default function AskScreen() { const debateId = open.chatId ? open.debateId : activeDebateRef.current; const [askSettings, debateContext] = await Promise.all([ askConfig(), - debateId ? buildAskDebateContext(userId, debateId, open.chatId) : Promise.resolve(undefined), + // Ask prepares no side, so every saved result matches it. + debateId ? buildDebateContext(userId, debateId, 'ask', 'both', open.chatId) : Promise.resolve(undefined), ]); if (!isCurrent()) return; const askRequest = { @@ -207,6 +200,10 @@ export default function AskScreen() { }; let result: AskOutput; + const whole: { extras?: ResponseExtras } = {}; + const keepExtras = (extras: ResponseExtras) => { + whole.extras = extras; + }; if (canStream(requestOptions)) { let received = ''; let pending = ''; @@ -238,16 +235,27 @@ export default function AskScreen() { } else { // Nothing arrived: ask once more without streaming, as the website does. setStreaming(null); - result = await askOnce(askRequest, controller.signal); + result = await askOnce(askRequest, controller.signal, keepExtras); } } finally { if (flush) clearTimeout(flush); } } else { - result = await askOnce(askRequest, controller.signal); + result = await askOnce(askRequest, controller.signal, keepExtras); } if (!isCurrent()) return; + // A whole answer ranks the Library sources it used and says when the question was shortened, as on the website. + if (whole.extras) { + const { truncated, assessments } = whole.extras; + const assessed = await keepSourceAssessments(userId, debateId, assessments, controller.signal); + if (!isCurrent()) return; + const notices = [ + ...(assessed ? [] : [`${t('aiClient.sourceSaveFailed')}. ${t('aiClient.sourceAssessmentSaveFailed')}`]), + ...(truncated ? [`${t('aiClient.inputShortened')}. ${t('aiClient.inputShortenedDesc')}`] : []), + ]; + if (notices.length > 0) setNotice(notices.join(' ')); + } const output = { ...result, model: result.model || askSettings.model, @@ -260,8 +268,8 @@ export default function AskScreen() { } catch (cause) { if (!isCurrent() || isAbortError(cause)) return; setStreaming(null); - const kind = cause instanceof AskError ? cause.kind : 'failed'; - if (!(cause instanceof AskError)) console.warn('Ask failed:', cause instanceof Error ? cause.message : cause); + const kind = cause instanceof AIError ? cause.kind : 'failed'; + if (!(cause instanceof AIError)) console.warn('Ask failed:', cause instanceof Error ? cause.message : cause); // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); setError({ kind, question: message }); @@ -274,6 +282,16 @@ export default function AskScreen() { } }; + // Discuss on a case or an attack opens Ask with the website's message and sends it once. + const prompted = useRef(false); + useEffect(() => { + if (prompted.current || !prompt?.trim() || !userId) return; + prompted.current = true; + void send(prompt, []); + // send reads refs and state setters; it runs once per opening. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prompt, userId]); + // Retrying replaces the unanswered question instead of asking it twice. const retry = (question: string) => { const last = messages[messages.length - 1]; diff --git a/apps/mobile/src/app/attack/[id].tsx b/apps/mobile/src/app/attack/[id].tsx new file mode 100644 index 000000000..69817e897 --- /dev/null +++ b/apps/mobile/src/app/attack/[id].tsx @@ -0,0 +1,102 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ActionMenu } from '@/components/action-menu'; +import { AttackDocument } from '@/components/attack-document'; +import { PrimaryButton, SecondaryButton } from '@/components/buttons'; +import { DocumentScreen, DocumentTitle } from '@/components/document'; +import { DocumentHeader, type HeaderAction } from '@/components/document-header'; +import { DocumentStatus } from '@/components/document-status'; +import { backLabel } from '@/lib/back-label'; +import { formatWhen } from '@/lib/dates'; +import { copyText, sharePdf, shareText } from '@/lib/export'; +import { attackExportFileName, attackExportHtml } from '@/lib/export-html'; +import { attackToText, discussAttackMessage } from '@/lib/result-text'; +import { isCompleteAttack } from '@/lib/results'; +import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; + +/** + * An attack analysis in the same document style as a case. Its pinned next + * step is the website's: Discuss, which takes the critique to Ask. + */ +export default function AttackResultScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id, from, notice: arrival, failed } = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>(); + const doc = useResultDocument('attack', id); + const [exportOpen, setExportOpen] = useState(false); + const { setNotice } = doc; + + useEffect(() => { + const text = arrivalNoticeText(t, arrival, failed); + if (text) setNotice(text); + }, [arrival, failed, setNotice, t]); + + const result = doc.state.status === 'ready' ? doc.state.result : null; + const output = result && isCompleteAttack(result.output) ? result.output : null; + const back = backLabel(t, from, 'practice'); + + if (!result || !output) { + return ( + }> + + + ); + } + + // The saved title is the summary cut at 80 characters; the summary already opens the document. + const title = t('export.caseAttack'); + const text = () => attackToText(output, t); + const run = (work: () => Promise) => { + work().catch((cause: unknown) => { + console.warn('Export failed:', cause instanceof Error ? cause.message : cause); + setNotice(t('mobile.results.exportFailed')); + }); + }; + const discuss = () => router.push({ pathname: '/ask', params: { prompt: discussAttackMessage(output) } }); + + const actions: HeaderAction[] = [ + ...(doc.autoSaved.length > 0 + ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] + : []), + ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), + ]; + + return ( + run(() => shareText(title, text()))} actions={actions} />} + actions={ + <> + + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + }> + + + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(attackExportHtml(output, t), attackExportFileName(output), title)); + if (choice === 'copy') run(async () => { + await copyText(text()); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + ); +} diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx new file mode 100644 index 000000000..696f6ee0b --- /dev/null +++ b/apps/mobile/src/app/attack/index.tsx @@ -0,0 +1,244 @@ +import type { AttackInput } from '@debatelab/core/ai/schemas/attack'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { MainActionButton } from '@/components/buttons'; +import { DocumentScreen } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; +import { Text } from '@/components/text'; +import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; +import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { buildDebateContext } from '@/lib/context-request'; +import { useDebates } from '@/lib/debate-context'; +import { fetchFeatureConfig } from '@/lib/feature-config'; +import { keepOpenResult, openResult } from '@/lib/open-results'; +import { caseToText } from '@/lib/result-text'; +import { arrivalNotices, attackCase, autosaveSources, fetchResult, isCompleteCase, keepSourceAssessments, recordResult } from '@/lib/results'; +import { sessionSlot } from '@/lib/session-memory'; +import { useSettings } from '@/lib/settings'; +import { useTheme } from '@/theme/use-theme'; + +// The website's defaults (AttackForm). The last form stays for the session, as on the website. +const DEFAULT_FORM: AttackInput = { + caseText: '', + aggressiveness: 'ruthless', + judgeParadigm: 'logic', + detectFallacies: true, + analyzeWeighing: true, +}; +const lastForm = sessionSlot(() => DEFAULT_FORM); + +const PROGRESS = ['attackForm.loadingMsg1', 'attackForm.loadingMsg2', 'attackForm.loadingMsg3', 'attackForm.loadingMsg4', 'attackForm.loadingMsg5']; + +/** + * Attack, as the website's AttackPage. Opened from a case, the case's text + * fills the form, as the website passes a case in through the Attack form. + */ +export default function AttackScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { from, caseKey } = useLocalSearchParams<{ from?: string; caseKey?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { activeDebateId, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + const { colors } = useTheme(); + const [form, setFormState] = useState(lastForm.get); + const [textError, setTextError] = useState(false); + const [phase, setPhase] = useState<'form' | 'attacking'>('form'); + const [error, setError] = useState(null); + const requests = useRef(0); + const abort = useRef(null); + const live = useRef(true); + + const setForm = useCallback((patch: Partial) => + setFormState((previous) => { + const next = { ...previous, ...patch }; + lastForm.set(next); + return next; + }), []); + + const abortActive = useCallback(() => { + requests.current += 1; + abort.current?.abort(); + abort.current = null; + }, []); + + useEffect(() => () => { + live.current = false; + abortActive(); + }, [abortActive]); + + // A case opened here fills the form with its text. + useEffect(() => { + if (!caseKey) return; + const open = openResult(caseKey, 'case'); + if (open && isCompleteCase(open.output)) { + setForm({ caseText: caseToText(open.output) }); + return; + } + if (!userId || caseKey.startsWith('local-')) return; + let active = true; + fetchResult(userId, 'case', caseKey) + .then((row) => { + if (active && row && isCompleteCase(row.output)) setForm({ caseText: caseToText(row.output) }); + }) + .catch((cause: unknown) => console.warn('Could not load the case to attack:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + }; + }, [caseKey, userId, setForm]); + + const submit = async () => { + if (!userId) return; + const input = { ...form, caseText: form.caseText.trim() }; + if (!input.caseText) { + setTextError(true); + return; + } + const forUser = userId; + // Attack critiques pasted text, not a motion, so it follows the Current Debate directly. + const debateId = activeDebateId; + abortActive(); + const request = requests.current; + const isCurrent = () => requests.current === request && live.current; + const controller = new AbortController(); + abort.current = controller; + setError(null); + setPhase('attacking'); + try { + const [config, debateContext] = await Promise.all([ + fetchFeatureConfig(forUser, 'attack'), + debateId ? buildDebateContext(forUser, debateId, 'attack', 'both') : Promise.resolve(undefined), + ]); + if (!isCurrent()) return; + const { result, discoveredSources, truncated, assessments } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + if (!isCurrent()) return; + const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); + if (!isCurrent()) return; + const sources = await autosaveSources(forUser, discoveredSources, 'attack', debateId, controller.signal); + if (!isCurrent()) return; + const title = result.overallAssessment?.summary?.slice(0, 80) || input.caseText.slice(0, 80); + let savedId: string | null = null; + let recordFailed = false; + if (debateId) { + try { + savedId = await recordResult(forUser, 'attack', { title, input, output: result, debateId, side: 'neutral' }); + } catch (cause) { + console.warn('Could not save the attack to the debate:', cause instanceof Error ? cause.message : cause); + recordFailed = true; + } + } + if (!isCurrent()) return; + const key = keepOpenResult({ kind: 'attack', savedId, title, side: 'neutral', debateId, createdAt: Date.now(), input, output: result, sources: discoveredSources }); + const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + router.replace({ + pathname: '/attack/[id]', + params: { + id: key, + from: caseKey ? 'case' : from ?? 'practice', + ...(notice ? { notice } : {}), + ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}), + }, + }); + } catch (cause) { + if (!isCurrent() || isAbortError(cause)) return; + const kind = cause instanceof AIError ? cause.kind : 'failed'; + if (!(cause instanceof AIError)) console.warn('Attack failed:', cause instanceof Error ? cause.message : cause); + if (kind === 'debateMissing') void refresh(); + setError(kind); + setPhase('form'); + } finally { + if (requests.current === request) abort.current = null; + } + }; + + const attacking = phase === 'attacking'; + + return ( + } + actions={attacking ? undefined : void submit()} />}> + + + {attacking ? ( + t(key))} + onCancel={() => { + abortActive(); + setPhase('form'); + }} + /> + ) : ( + <> + { + setForm({ caseText }); + if (caseText.trim()) setTextError(false); + }} + placeholder={t('attackForm.argumentPlaceholder')} + multiline + invalid={textError} + style={styles.caseText} + testID="attack-case-text" + /> + {textError ? ( + + {t('mobile.attack.caseRequired')} + + ) : null} + + + first + label={t('attackForm.intensity')} + value={form.aggressiveness} + onChange={(aggressiveness) => setForm({ aggressiveness })} + options={[ + { value: 'mild', label: t('attackForm.intensityMild') }, + { value: 'moderate', label: t('attackForm.intensityModerate') }, + { value: 'ruthless', label: t('attackForm.intensityRuthless') }, + ]} + /> + + label={t('attackForm.paradigm')} + value={form.judgeParadigm} + onChange={(judgeParadigm) => setForm({ judgeParadigm })} + options={[ + { value: 'logic', label: t('attackForm.paradigmLogic') }, + { value: 'values', label: t('attackForm.paradigmValues') }, + { value: 'policy', label: t('attackForm.paradigmPolicy') }, + ]} + /> + setForm({ detectFallacies })} + /> + setForm({ analyzeWeighing })} + /> + + {error ? void submit()} /> : null} + + )} + + ); +} + +const styles = StyleSheet.create({ + caseText: { minHeight: 200, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + invalid: { fontSize: 14, marginTop: -6, marginLeft: 4 }, +}); diff --git a/apps/mobile/src/app/case/[id].tsx b/apps/mobile/src/app/case/[id].tsx new file mode 100644 index 000000000..699221708 --- /dev/null +++ b/apps/mobile/src/app/case/[id].tsx @@ -0,0 +1,110 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ActionMenu } from '@/components/action-menu'; +import { PrimaryButton, SecondaryButton } from '@/components/buttons'; +import { CaseDocument } from '@/components/case-document'; +import { openCitation } from '@/components/citation-target'; +import { DocumentScreen, DocumentTitle } from '@/components/document'; +import { DocumentHeader, type HeaderAction } from '@/components/document-header'; +import { DocumentStatus } from '@/components/document-status'; +import { backLabel } from '@/lib/back-label'; +import { formatWhen } from '@/lib/dates'; +import { copyText, sharePdf, shareText } from '@/lib/export'; +import { caseExportFileName, caseExportHtml } from '@/lib/export-html'; +import { caseToText, discussCaseMessage } from '@/lib/result-text'; +import { isCompleteCase } from '@/lib/results'; +import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; + +/** A generated case read as a document, as in mock 03. */ +export default function CaseScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id, from, notice: arrival, failed } = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>(); + const doc = useResultDocument('case', id); + const [exportOpen, setExportOpen] = useState(false); + const { setNotice } = doc; + + // Problems from saving a fresh result are told once, on arrival. + useEffect(() => { + const text = arrivalNoticeText(t, arrival, failed); + if (text) setNotice(text); + }, [arrival, failed, setNotice, t]); + + const result = doc.state.status === 'ready' ? doc.state.result : null; + const output = result && isCompleteCase(result.output) ? result.output : null; + const back = backLabel(t, from, 'prepare'); + + if (!result || !output) { + return ( + }> + + + ); + } + + const key = doc.state.status === 'ready' ? doc.state.key : id; + const title = result.title || output.metadata?.motion || t('export.debateCase'); + const side = output.metadata?.side === 'pro' ? t('generateResults.proposition') : t('generateResults.opposition'); + const generatedAt = Date.parse(output.metadata?.generatedAt ?? '') || result.createdAt; + + const run = (work: () => Promise) => { + work().catch((cause: unknown) => { + console.warn('Export failed:', cause instanceof Error ? cause.message : cause); + setNotice(t('mobile.results.exportFailed')); + }); + }; + + const actions: HeaderAction[] = [ + { key: 'discuss', label: t('common.discuss'), symbol: 'bubble.left.and.bubble.right', icon: 'chat', onPress: () => router.push({ pathname: '/ask', params: { prompt: discussCaseMessage(output) } }) }, + ...(result.savedId ? [] : [{ key: 'save', label: t('common.save'), symbol: 'square.and.arrow.down' as const, icon: 'download' as const, onPress: () => void doc.save() }]), + ...(doc.autoSaved.length > 0 + ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] + : []), + ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), + ]; + + return ( + run(() => shareText(title, caseToText(output)))} actions={actions} />} + actions={ + <> + router.push({ pathname: '/attack', params: { caseKey: key } })} + testID="attack-this-case" + /> + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + }> + + openCitation(router, target)} /> + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(caseExportHtml(output, t), caseExportFileName(output), title)); + if (choice === 'copy') run(async () => { + await copyText(caseToText(output)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + ); +} diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx new file mode 100644 index 000000000..ca7192b1b --- /dev/null +++ b/apps/mobile/src/app/generate.tsx @@ -0,0 +1,293 @@ +import type { GenerateInput } from '@debatelab/core/ai/schemas/generate'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { MainActionButton } from '@/components/buttons'; +import { DocumentScreen } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SelectRow } from '@/components/form-rows'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Text } from '@/components/text'; +import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; +import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { buildDebateContext } from '@/lib/context-request'; +import { useDebates } from '@/lib/debate-context'; +import { fetchFeatureConfig } from '@/lib/feature-config'; +import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; +import { chooseMotionTarget } from '@/lib/motion-gate'; +import { keepOpenResult } from '@/lib/open-results'; +import { arrivalNotices, autosaveSources, generateCase, keepSourceAssessments, recordResult } from '@/lib/results'; +import { sessionSlot } from '@/lib/session-memory'; +import { useSettings } from '@/lib/settings'; +import { useTheme } from '@/theme/use-theme'; + +// The website's defaults (GenerateForm). The website keeps the last form for the session; so does the app. +const DEFAULT_FORM: GenerateInput = { + motion: '', + side: 'pro', + format: 'custom', + role: 'r1', + length: 'standard', + audience: 'intermediate', + tone: 'formal', +}; +const lastForm = sessionSlot(() => DEFAULT_FORM); + +const PROGRESS = ['generateForm.loadingMsg1', 'generateForm.loadingMsg2', 'generateForm.loadingMsg3', 'generateForm.loadingMsg4', 'generateForm.loadingMsg5']; + +/** Generate, as the website's GeneratePage: a native form, a calm wait, then the case as a document. */ +export default function GenerateScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { from } = useLocalSearchParams<{ from?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + const { colors } = useTheme(); + const [form, setFormState] = useState(lastForm.get); + const [motionError, setMotionError] = useState(false); + const [phase, setPhase] = useState<'form' | 'generating'>('form'); + const [error, setError] = useState(null); + const requests = useRef(0); + const abort = useRef(null); + const live = useRef(true); + + const setForm = useCallback((patch: Partial) => + setFormState((previous) => { + const next = { ...previous, ...patch }; + lastForm.set(next); + return next; + }), []); + + const abortActive = useCallback(() => { + requests.current += 1; + abort.current?.abort(); + abort.current = null; + }, []); + + // Leaving cancels a case still on its way, and nothing is saved. + useEffect(() => () => { + live.current = false; + abortActive(); + }, [abortActive]); + + // The motion follows the Current Debate unless the student typed their own. + useEffect(() => { + if (oneOff || !current) return; + if (shouldPrefillMotion(lastForm.get().motion, current.motion, debates.map((debate) => debate.motion))) setForm({ motion: current.motion }); + }, [current, debates, oneOff, setForm]); + + const start = async (input: GenerateInput, debateId: string | null, forUser: string) => { + abortActive(); + const request = requests.current; + const isCurrent = () => requests.current === request && live.current; + const controller = new AbortController(); + abort.current = controller; + setError(null); + setPhase('generating'); + try { + const [config, debateContext] = await Promise.all([ + fetchFeatureConfig(forUser, 'generate'), + debateId ? buildDebateContext(forUser, debateId, 'generate', input.side) : Promise.resolve(undefined), + ]); + if (!isCurrent()) return; + const { result, discoveredSources, truncated, assessments } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + if (!isCurrent()) return; + const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); + if (!isCurrent()) return; + const sources = await autosaveSources(forUser, discoveredSources, 'generate', debateId, controller.signal); + if (!isCurrent()) return; + let savedId: string | null = null; + let recordFailed = false; + if (debateId) { + try { + savedId = await recordResult(forUser, 'case', { title: input.motion, input, output: result, debateId, side: input.side }); + } catch (cause) { + console.warn('Could not save the case to the debate:', cause instanceof Error ? cause.message : cause); + recordFailed = true; + } + } + if (!isCurrent()) return; + const key = keepOpenResult({ + kind: 'case', + savedId, + title: input.motion, + side: input.side, + debateId, + createdAt: Date.now(), + input, + output: result, + sources: discoveredSources, + }); + const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + router.replace({ + pathname: '/case/[id]', + params: { id: key, from: from ?? 'prepare', ...(notice ? { notice } : {}), ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}) }, + }); + } catch (cause) { + if (!isCurrent() || isAbortError(cause)) return; + const kind = cause instanceof AIError ? cause.kind : 'failed'; + if (!(cause instanceof AIError)) console.warn('Generate failed:', cause instanceof Error ? cause.message : cause); + // A missing debate was deleted elsewhere; reload the list so the app stops using it. + if (kind === 'debateMissing') void refresh(); + setError(kind); + setPhase('form'); + } finally { + if (requests.current === request) abort.current = null; + } + }; + + const submit = async () => { + if (!userId) return; + const input = { ...form, motion: form.motion.trim() }; + if (!input.motion) { + setMotionError(true); + return; + } + const forUser = userId; + // Where the case belongs is settled before anything is sent, as the website's motion gate does. + const decision = decideMotionTarget(input.motion, { debates, current, oneOff }); + if (decision.kind !== 'ask') { + void start(input, decision.kind === 'attach' ? decision.debateId : null, forUser); + return; + } + const choice = await chooseMotionTarget(decision.matches, { + title: current ? t('debate.mismatchTitle') : t('debate.offerTitle'), + message: `“${input.motion}”\n\n${current ? t('debate.mismatchDesc', { motion: current.motion }) : t('debate.offerDesc')}`, + switchLabel: t('debate.switchToExisting'), + createLabel: current ? t('debate.createFromMotion') : t('debate.startDebate'), + onceLabel: current ? t('debate.useOnce') : t('debate.offerNotNow'), + cancel: t('common.cancel'), + }); + if (!choice || !live.current) return; + let debateId: string | null = null; + try { + if (choice.kind === 'switch') { + await switchTo(choice.debateId); + debateId = choice.debateId; + } else if (choice.kind === 'create') { + debateId = (await create({ motion: input.motion, settings: {} })).id; + } + } catch (cause) { + console.warn('Could not save the debate:', cause instanceof Error ? cause.message : cause); + if (live.current) setError('debate'); + return; + } + if (live.current) void start(input, debateId, forUser); + }; + + const cancel = () => { + abortActive(); + setPhase('form'); + }; + + const generating = phase === 'generating'; + + return ( + } + actions={generating ? undefined : void submit()} />}> + + + {generating ? ( + t(key))} detail={form.motion} onCancel={cancel} /> + ) : ( + <> + { + setForm({ motion }); + if (motion.trim()) setMotionError(false); + }} + placeholder={t('generateForm.motionPlaceholder')} + multiline + invalid={motionError} + style={styles.motion} + testID="generate-motion" + /> + {motionError ? ( + + {t('debate.motionRequired')} + + ) : null} + + + {t('generateForm.side')} + + + options={[ + { value: 'pro', label: t('generateForm.sidePro') }, + { value: 'con', label: t('generateForm.sideCon') }, + ]} + value={form.side} + onChange={(side) => setForm({ side })} + /> + + + + first + label={t('generateForm.role')} + value={form.role} + onChange={(role) => setForm({ role })} + options={[ + { value: 'intro', label: t('generateForm.roleIntro') }, + { value: 'r1', label: t('generateForm.roleR1') }, + { value: 'r2', label: t('generateForm.roleR2') }, + { value: 'conc', label: t('generateForm.roleConc') }, + ]} + /> + + + + first + label={t('generateForm.length')} + value={form.length} + onChange={(length) => setForm({ length })} + options={[ + { value: 'brief', label: t('generateForm.lengthBrief') }, + { value: 'standard', label: t('generateForm.lengthStandard') }, + { value: 'comprehensive', label: t('generateForm.lengthComprehensive') }, + ]} + /> + + label={t('generateForm.audience')} + value={form.audience} + onChange={(audience) => setForm({ audience })} + options={[ + { value: 'beginner', label: t('generateForm.audienceBeginner') }, + { value: 'intermediate', label: t('generateForm.audienceIntermediate') }, + { value: 'advanced', label: t('generateForm.audienceAdvanced') }, + ]} + /> + + label={t('generateForm.tone')} + value={form.tone} + onChange={(tone) => setForm({ tone })} + options={[ + { value: 'formal', label: t('generateForm.toneFormal') }, + { value: 'conversational', label: t('generateForm.toneConversational') }, + { value: 'aggressive', label: t('generateForm.toneAggressive') }, + ]} + /> + + {error ? void submit()} /> : null} + + )} + + ); +} + +const styles = StyleSheet.create({ + motion: { minHeight: 92, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + invalid: { fontSize: 14, marginTop: -6, marginLeft: 4 }, + side: { gap: 7 }, + label: { fontSize: 14, marginLeft: 4 }, +}); diff --git a/apps/mobile/src/components/action-menu.tsx b/apps/mobile/src/components/action-menu.tsx new file mode 100644 index 000000000..429fb096d --- /dev/null +++ b/apps/mobile/src/components/action-menu.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react'; +import { ActionSheetIOS, Alert, Platform } from 'react-native'; + +import type { IconName } from './icon-paths'; + +export interface MenuItem { + key: string; + label: string; + icon?: IconName; + destructive?: boolean; +} + +export interface ActionMenuProps { + visible: boolean; + title?: string; + items: MenuItem[]; + cancelLabel: string; + onSelect: (key: string) => void; + onClose: () => void; + /** Where the web preview draws it; iOS always uses the action sheet. */ + placement?: 'top' | 'bottom'; + testID?: string; +} + +/** A native iOS action sheet, opened while `visible` is true. The web preview draws its own (action-menu.web.tsx). */ +export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClose }: ActionMenuProps) { + const latest = useRef({ items, onSelect, onClose }); + latest.current = { items, onSelect, onClose }; + useEffect(() => { + if (!visible) return; + let live = true; + const choose = (index: number | undefined) => { + if (!live) return; + const { items: current, onSelect: select, onClose: close } = latest.current; + close(); + const item = index === undefined ? undefined : current[index]; + if (item) select(item.key); + }; + const labels = latest.current.items.map((item) => item.label); + if (Platform.OS === 'ios') { + ActionSheetIOS.showActionSheetWithOptions( + { + title, + options: [...labels, cancelLabel], + cancelButtonIndex: labels.length, + destructiveButtonIndex: latest.current.items.flatMap((item, index) => (item.destructive ? [index] : [])), + }, + (index) => choose(index < labels.length ? index : undefined), + ); + } else { + Alert.alert(title ?? '', undefined, [ + ...labels.map((label, index) => ({ text: label, onPress: () => choose(index) })), + { text: cancelLabel, style: 'cancel' as const, onPress: () => choose(undefined) }, + ], { cancelable: true, onDismiss: () => choose(undefined) }); + } + return () => { + live = false; + }; + // Shown once per opening; the latest items and handlers are read from the ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + return null; +} diff --git a/apps/mobile/src/components/action-menu.web.tsx b/apps/mobile/src/components/action-menu.web.tsx new file mode 100644 index 000000000..651777f94 --- /dev/null +++ b/apps/mobile/src/components/action-menu.web.tsx @@ -0,0 +1,51 @@ +import { Modal, Pressable, StyleSheet, View } from 'react-native'; + +import type { ActionMenuProps } from './action-menu'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +/** react-native-web has no action sheet, so the web preview draws a menu like the Library's. */ +export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClose, placement = 'bottom', testID }: ActionMenuProps) { + const { colors } = useTheme(); + if (!visible) return null; + return ( + + + + {title ? ( + + {title} + + ) : null} + {items.map((item, index) => ( + { + onClose(); + onSelect(item.key); + }} + style={({ pressed }) => [styles.item, (index > 0 || title) && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && { backgroundColor: colors.line }]}> + {item.label} + {item.icon ? : null} + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(15,23,42,0.18)', paddingHorizontal: 14 }, + top: { alignItems: 'flex-end', paddingTop: 58 }, + bottom: { justifyContent: 'flex-end', paddingBottom: 28 }, + menu: { borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', boxShadow: '0 14px 40px -10px rgba(15,23,42,0.4)' }, + menuTop: { width: 268 }, + menuBottom: { width: '100%', maxWidth: 520, alignSelf: 'center' }, + title: { fontSize: 13.5, textAlign: 'center', paddingVertical: 12, paddingHorizontal: 16 }, + item: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 17, minHeight: 52, gap: 12 }, + label: { fontSize: 17, flexShrink: 1 }, +}); diff --git a/apps/mobile/src/components/ask-answer.tsx b/apps/mobile/src/components/ask-answer.tsx index 9e2205200..8015100e5 100644 --- a/apps/mobile/src/components/ask-answer.tsx +++ b/apps/mobile/src/components/ask-answer.tsx @@ -6,35 +6,12 @@ import { useTranslation } from 'react-i18next'; import { ScrollView, StyleSheet, View } from 'react-native'; import { Chip, CiteBadge } from './chip'; +import { firstUrl, hostOf, openCitation, shorten, type CitationTarget } from './citation-target'; import { MarkdownView } from './markdown-view'; import { Text } from './text'; import type { Source } from '@/lib/sources'; -interface CitationTarget { - n: number; - label: string; - url?: string; - sourceId?: string; -} - -/** The first web address in a citation's source, as the website's getFirstUrl. */ -function firstUrl(text?: string): string | undefined { - return text?.match(/https?:\/\/[^\s]+/i)?.[0]?.replace(/[),.;!?]+$/g, ''); -} - -function hostOf(url: string): string { - try { - return new URL(url).hostname.replace(/^www\./, ''); - } catch { - return url; - } -} - -function shorten(text: string, max = 42): string { - return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text; -} - /** Each citation as a chip: a Library source opens its detail, a web address opens the in-app browser. */ function citationTargets(output: AskOutput | undefined, library: Source[]): CitationTarget[] { return (output?.citations ?? []).map((citation, index) => { @@ -62,10 +39,7 @@ export function AskAnswer({ content, output, streaming, library }: { content: st const cited = new Set(targets.map((target) => target.url).filter(Boolean)); const webSources = (output?.searchInfo?.webSearchUsed ? output.searchInfo.sources : []).filter((item) => !cited.has(item.url)); - const open = (target: CitationTarget | undefined) => { - if (target?.sourceId) router.push({ pathname: '/source/[id]', params: { id: target.sourceId } }); - else if (target?.url) void WebBrowser.openBrowserAsync(target.url); - }; + const open = (target: CitationTarget | undefined) => openCitation(router, target); return ( diff --git a/apps/mobile/src/components/attack-document.tsx b/apps/mobile/src/components/attack-document.tsx new file mode 100644 index 000000000..2062386d9 --- /dev/null +++ b/apps/mobile/src/components/attack-document.tsx @@ -0,0 +1,166 @@ +import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; +import type { AttackOutput, Weakness } from '@debatelab/core/ai/schemas/attack'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { ResearchedSources } from './case-document'; +import { Bullets, Callout, Divider, DocSection, Labeled, NumberedItems, Paragraph, Quote, SectionHeading, SubHeading, Tag } from './document'; +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +const SEVERITIES = ['critical', 'major', 'minor'] as const; + +/** Every part of the website's AttackResults, in the same document style as a case. */ +export function AttackDocument({ output, sources }: { output: AttackOutput; sources: DiscoveredSource[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const severityColor = { critical: colors.destructive, major: colors.warning, minor: colors.primary }; + const weaknesses = Array.isArray(output.weaknesses) ? output.weaknesses : []; + const fallacies = Array.isArray(output.fallaciesDetected) ? output.fallaciesDetected : []; + const crossEx = Array.isArray(output.crossExQuestions) ? output.crossExQuestions : []; + const overall = output.overallAssessment ?? { summary: '', score: 0, strengthAreas: [] }; + const strengths = Array.isArray(overall.strengthAreas) ? overall.strengthAreas : []; + const score = Math.max(0, Math.min(100, overall.score)); + const scoreColor = score >= 70 ? colors.primary : score >= 50 ? colors.warning : colors.destructive; + const severityOf = (weakness: Weakness) => (SEVERITIES.includes(weakness.severity) ? weakness.severity : 'minor'); + + const items = weaknesses.map((weakness, index) => { + const severity = severityOf(weakness); + const severityLabel = t(`attackResults.${severity}`); + const typeLabel = t(`attackResults.types.${weakness.type}`, { defaultValue: weakness.type }); + return { + // Model ids can repeat, so the expansion state is keyed by position. + key: `${index}`, + title: weakness.description, + summary: `${severityLabel} · ${typeLabel}`, + body: ( + <> + + + + + {weakness.affectedQuote ? : null} + {t('attackResults.whyWeak')} + + + + + + + ), + }; + }); + + return ( + <> + + + + {t('attackResults.caseStrength')} + + + {`${overall.score}/100`} + + + + + + {overall.summary ? ( + + {overall.summary} + + ) : null} + + {SEVERITIES.map((severity) => ( + + + + {`${weaknesses.filter((weakness) => severityOf(weakness) === severity).length} ${t(`attackResults.${severity}`)}`} + + + ))} + + + + {strengths.length > 0 ? ( + + + + ) : null} + + + + + {fallacies.length > 0 ? ( + + {fallacies.map((fallacy, index) => ( + + {index > 0 ? : null} + + + + {`${t('attackResults.in')} ${fallacy.location}`} + + + + + ))} + + ) : null} + + + {crossEx.map((question, index) => ( + + {index > 0 ? : null} + + {`"${question.question}"`} + + + + + ))} + + + {output.weighingAnalysis ? ( + + {t('attackResults.currentWeighing')} + + {output.weighingAnalysis.currentWeighing} + + + {t('attackResults.missingComparisons')} + + + {t('attackResults.improvements')} + + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + score: { borderWidth: 1, borderRadius: 17, paddingHorizontal: 16, paddingVertical: 14, gap: 10 }, + scoreRow: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between' }, + scoreLabel: { fontSize: 15 }, + scoreValue: { fontSize: 22 }, + track: { height: 10, borderRadius: 5, overflow: 'hidden' }, + fill: { height: '100%', borderRadius: 5 }, + summary: { fontSize: 16, lineHeight: 23 }, + counts: { flexDirection: 'row', flexWrap: 'wrap', gap: 14 }, + count: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + dot: { width: 10, height: 10, borderRadius: 5 }, + countText: { fontSize: 14.5 }, + tags: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6 }, + entry: { gap: 8 }, + location: { fontSize: 13.5 }, + question: { fontSize: 15.5, lineHeight: 22 }, + body: { fontSize: 15.5, lineHeight: 22 }, +}); diff --git a/apps/mobile/src/components/buttons.tsx b/apps/mobile/src/components/buttons.tsx index 6dd81b607..1a69af448 100644 --- a/apps/mobile/src/components/buttons.tsx +++ b/apps/mobile/src/components/buttons.tsx @@ -45,6 +45,8 @@ export function PrimaryButton({ busy, disabled, destructive, + floating, + testID, }: { label: string; icon?: IconName; @@ -52,8 +54,11 @@ export function PrimaryButton({ busy?: boolean; disabled?: boolean; destructive?: boolean; + /** Pinned above the content, as the mocks' bottom actions are. */ + floating?: boolean; + testID?: string; }) { - const { colors } = useTheme(); + const { colors, scheme } = useTheme(); const inactive = disabled || busy; const foreground = destructive ? '#ffffff' : colors.onPrimary; return ( @@ -62,9 +67,11 @@ export function PrimaryButton({ accessibilityState={{ disabled: inactive, busy }} disabled={inactive} onPress={onPress} + testID={testID} style={({ pressed }) => [ styles.primary, { backgroundColor: destructive ? colors.destructive : colors.primary }, + floating && scheme === 'light' && styles.primaryShadow, (pressed || inactive) && styles.dimmed, ]}> {busy ? : icon ? : null} @@ -75,6 +82,45 @@ export function PrimaryButton({ ); } +/** The outlined button beside a primary one, as "Attack this case" in mock 03. */ +export function SecondaryButton({ + label, + icon, + onPress, + disabled, + floating, + testID, +}: { + label: string; + icon?: IconName; + onPress: () => void; + disabled?: boolean; + floating?: boolean; + testID?: string; +}) { + const { colors, scheme } = useTheme(); + return ( + [ + styles.primary, + styles.secondary, + { backgroundColor: colors.input, borderColor: colors.border }, + floating && scheme === 'light' && styles.secondaryShadow, + (pressed || disabled) && styles.dimmed, + ]}> + {icon ? : null} + + {label} + + + ); +} + /** A secondary action shown as tinted text, like an iOS plain button. */ export function TextButton({ label, @@ -124,7 +170,11 @@ const styles = StyleSheet.create({ gap: 8, paddingHorizontal: 20, }, - primaryLabel: { fontSize: 17 }, + primaryLabel: { fontSize: 17, flexShrink: 1 }, + primaryShadow: { boxShadow: '0 6px 18px -8px rgba(2,132,199,0.6)' }, + // The mock's outlined button has almost no side padding, so its label fits half the width. + secondary: { borderWidth: 1, paddingHorizontal: 6, gap: 6 }, + secondaryShadow: { boxShadow: '0 6px 18px -8px rgba(15,23,42,0.35)' }, dimmed: { opacity: 0.6 }, text: { minHeight: 44, alignItems: 'center', justifyContent: 'center' }, textLabel: { fontSize: 16 }, diff --git a/apps/mobile/src/components/case-document.tsx b/apps/mobile/src/components/case-document.tsx new file mode 100644 index 000000000..01a80fbda --- /dev/null +++ b/apps/mobile/src/components/case-document.tsx @@ -0,0 +1,241 @@ +import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; +import type { Contention, GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import * as WebBrowser from 'expo-web-browser'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { hostOf, shorten, type CitationTarget } from './citation-target'; +import { + Bullets, + Callout, + Divider, + DocSection, + FramingCard, + Labeled, + NumberedItems, + Paragraph, + Quote, + SubHeading, + Tag, +} from './document'; +import { Icon } from './icon'; +import { Text } from './text'; + +import type { Source } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +type SourceUsage = NonNullable[number]; + +const MIN_TITLE_MATCH = 4; + +/** + * The sources an argument names, as citation chips. The server records which + * sources the case used (metadata.sourceUsage); an argument gets a chip for + * each one whose title or site it mentions, then for Library sources it names, + * as Ask's answers match them. Numbers stay the same across arguments. + */ +function argumentChips(contention: Contention, usage: SourceUsage[], library: Source[]): CitationTarget[] { + const text = `${contention.claim} ${contention.reasoning} ${contention.evidence} ${contention.impact}`.toLocaleLowerCase(); + const mentions = (title: string) => { + const needle = title.trim().toLocaleLowerCase(); + return needle.length >= MIN_TITLE_MATCH && text.includes(needle); + }; + const chips: CitationTarget[] = []; + const libraryIds = new Set(library.map((source) => source.id)); + usage.forEach((item, index) => { + if (item.judgment === 'not_used') return; + const host = item.origin && /^https?:\/\//i.test(item.origin) ? hostOf(item.origin) : ''; + if (!mentions(item.title) && !(host && text.includes(host.toLocaleLowerCase()))) return; + chips.push({ + n: index + 1, + label: shorten(item.title || host), + sourceId: item.id && libraryIds.has(item.id) ? item.id : undefined, + url: item.origin && /^https?:\/\//i.test(item.origin) ? item.origin : undefined, + }); + }); + const named = new Set(usage.map((item) => item.id).filter(Boolean)); + library.forEach((source, index) => { + if (named.has(source.id) || !mentions(source.title)) return; + chips.push({ n: usage.length + index + 1, label: shorten(source.title), sourceId: source.id }); + }); + return chips; +} + +/** Every part of the website's GenerateResults, in mock 03's document style. */ +export function CaseDocument({ + output, + library, + sources, + onCite, +}: { + output: GenerateOutput; + library: Source[]; + /** Researched sources, known only for a case generated in this session. */ + sources: DiscoveredSource[]; + onCite: (target: CitationTarget) => void; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const chipsByArgument = useMemo( + () => output.contentions.map((contention) => argumentChips(contention, output.metadata?.sourceUsage ?? [], library)), + [output, library], + ); + + const items = output.contentions.map((contention, index) => { + const chips = chipsByArgument[index]; + const pieces = chips.length || (contention.evidence.trim() ? 1 : 0); + return { + key: `${index}`, + title: contention.claim, + summary: pieces > 0 + ? t('mobile.results.evidenceCount', { count: pieces }) + : contention.needsSource ? t('generateResults.needsSource') : t('mobile.results.noEvidence'), + body: ( + <> + {contention.needsSource ? : null} + + {contention.evidence.trim() ? : null} + {contention.sourceQuery ? : null} + {contention.impact ? : null} + + ), + }; + }); + + const durationMs = output.metadata?.durationMs; + const duration = durationMs && durationMs > 0 + ? durationMs < 1000 + ? t('generateResults.durationMs', { value: durationMs }) + : t('generateResults.durationS', { value: (durationMs / 1000).toFixed(durationMs < 10000 ? 1 : 0) }) + : null; + const model = output.metadata?.model || output.metadata?.provider; + const footer = [model ? t('generateResults.modelLabel', { value: model }) : null, duration].filter(Boolean).join(' • '); + const definitions = output.caseStructure.keyDefinitions ?? []; + + return ( + <> + block.text?.trim())} + /> + + {output.exordio && (output.exordio.hook || output.exordio.transition) ? ( + + {t('generateResults.hook')} + + {output.exordio.hook} + + + {t('generateResults.transition')} + + {output.exordio.transition} + + + ) : null} + + + + + {t('generateResults.burdenOfProof')} + + + {t('generateResults.keyDefinitions')} + {definitions.length > 0 ? : {t('generateResults.noDefinitions')}} + + + + {t('generateResults.likelyArguments')} + + + {t('generateResults.strategicWeaknesses')} + + + {t('generateResults.keyClashes')} + + {(output.predictedOpponentCase.keyClashes ?? []).map((clash, index) => ( + + ))} + + + + + {output.counters.map((counter, index) => ( + + {index > 0 ? : null} + + + + {counter.targetArgument} + + + + + + {counter.rebuttal} + + + + ))} + + + + + {footer ? ( + + {footer} + + ) : null} + + ); +} + +/** The website's researched-sources panel: each source with its quality, opening in the in-app browser. */ +export function ResearchedSources({ sources }: { sources: DiscoveredSource[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (sources.length === 0) return null; + return ( + + {sources.map((source, index) => ( + void WebBrowser.openBrowserAsync(source.url)} + style={({ pressed }) => [styles.source, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + + {source.title} + + + {[ + hostOf(source.url), + source.qualityLabel ? t(`sourceQuality.labels.${source.qualityLabel}`) : null, + source.qualityLabel === 'weak' ? t('sourceQuality.fallbackOnly') : null, + source.semantic?.lastUsedInCase ? t('sourceQuality.usedInCase') : null, + ].filter(Boolean).join(' · ')} + + + + ))} + + ); +} + +const styles = StyleSheet.create({ + body: { fontSize: 15.5, lineHeight: 22 }, + italic: { fontSize: 15.5, lineHeight: 22, fontStyle: 'italic' }, + tags: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + counter: { gap: 8 }, + counterTarget: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 }, + counterTargetText: { flex: 1, fontSize: 15.5, lineHeight: 21 }, + footer: { fontSize: 13, textAlign: 'center', marginTop: 6 }, + source: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8 }, + sourceText: { flex: 1, gap: 2 }, + sourceTitle: { fontSize: 15, lineHeight: 20 }, + sourceMeta: { fontSize: 13 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/components/citation-target.ts b/apps/mobile/src/components/citation-target.ts new file mode 100644 index 000000000..fe6dc5b20 --- /dev/null +++ b/apps/mobile/src/components/citation-target.ts @@ -0,0 +1,32 @@ +import type { useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; + +/** Where a citation chip leads: a Library source opens its detail, a web address opens the in-app browser. */ +export interface CitationTarget { + n: number; + label: string; + url?: string; + sourceId?: string; +} + +/** The first web address in a citation's source, as the website's getFirstUrl. */ +export function firstUrl(text?: string): string | undefined { + return text?.match(/https?:\/\/[^\s]+/i)?.[0]?.replace(/[),.;!?]+$/g, ''); +} + +export function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } +} + +export function shorten(text: string, max = 42): string { + return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text; +} + +export function openCitation(router: ReturnType, target: CitationTarget | undefined) { + if (target?.sourceId) router.push({ pathname: '/source/[id]', params: { id: target.sourceId } }); + else if (target?.url) void WebBrowser.openBrowserAsync(target.url); +} diff --git a/apps/mobile/src/components/document-header.tsx b/apps/mobile/src/components/document-header.tsx new file mode 100644 index 000000000..e300e3da1 --- /dev/null +++ b/apps/mobile/src/components/document-header.tsx @@ -0,0 +1,57 @@ +import { Stack } from 'expo-router'; +import type { ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { IconName } from './icon-paths'; + +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +type SFSymbol = Extract['icon'], string>; + +export interface HeaderAction { + key: string; + label: string; + /** The SF Symbol on iOS, and the icon the web preview draws. */ + symbol: SFSymbol; + icon: IconName; + destructive?: boolean; + onPress: () => void; +} + +/** + * The document's navigation bar from mock 03: back to where it was opened, + * a share button and a "..." menu, in the native iOS header. + */ +export function DocumentHeader({ backLabel, onShare, actions }: { backLabel: string; onShare?: () => void; actions: HeaderAction[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + <> + + + {onShare ? : null} + {actions.length > 0 ? ( + + {actions.map((action) => ( + + {action.label} + + ))} + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/document-header.web.tsx b/apps/mobile/src/components/document-header.web.tsx new file mode 100644 index 000000000..194602818 --- /dev/null +++ b/apps/mobile/src/components/document-header.web.tsx @@ -0,0 +1,59 @@ +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ActionMenu } from './action-menu'; +import type { HeaderAction } from './document-header'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +/** The web preview has no native header, so it draws mock 03's bar and its menu. */ +export function DocumentHeader({ backLabel, onShare, actions }: { backLabel: string; onShare?: () => void; actions: HeaderAction[] }) { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const [open, setOpen] = useState(false); + return ( + + router.back()} style={styles.back} testID="document-back"> + + + {backLabel} + + + + {onShare ? ( + + + + ) : null} + {actions.length > 0 ? ( + setOpen(true)} hitSlop={8} style={styles.icon} testID="document-more"> + + + ) : null} + + ({ key, label, icon, destructive }))} + cancelLabel={t('common.cancel')} + onClose={() => setOpen(false)} + onSelect={(key) => actions.find((action) => action.key === key)?.onPress()} + /> + + ); +} + +const styles = StyleSheet.create({ + bar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 10, paddingBottom: 6 }, + back: { flexDirection: 'row', alignItems: 'center', minHeight: 44, flexShrink: 1 }, + backLabel: { fontSize: 18 }, + actions: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingRight: 8 }, + icon: { minWidth: 36, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/apps/mobile/src/components/document-status.tsx b/apps/mobile/src/components/document-status.tsx new file mode 100644 index 000000000..52cebe2d7 --- /dev/null +++ b/apps/mobile/src/components/document-status.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { Text } from './text'; + +/** What a document screen shows before its result is ready, or when it cannot be shown. */ +export function DocumentStatus({ status, incomplete }: { status: 'loading' | 'missing' | 'failed' | 'incomplete'; incomplete?: { title: string; body: string } }) { + const { t } = useTranslation(); + if (status === 'loading') return ; + return ( + + {status === 'incomplete' && incomplete ? ( + <> + + {incomplete.title} + + + {incomplete.body} + + + ) : ( + + {status === 'missing' ? t('mobile.results.notFound') : t('mobile.results.loadFailed')} + + )} + + ); +} + +const styles = StyleSheet.create({ + loading: { marginTop: 48 }, + box: { gap: 6, marginTop: 24 }, + title: { fontSize: 18 }, + body: { fontSize: 16, lineHeight: 22 }, +}); diff --git a/apps/mobile/src/components/document.tsx b/apps/mobile/src/components/document.tsx new file mode 100644 index 000000000..e24ca92eb --- /dev/null +++ b/apps/mobile/src/components/document.tsx @@ -0,0 +1,357 @@ +import { Children, Fragment, isValidElement, useState, type ReactNode } from 'react'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Chip, CiteBadge } from './chip'; +import type { CitationTarget } from './citation-target'; +import { Icon } from './icon'; +import { MarkdownView } from './markdown-view'; +import { Text } from './text'; + +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * A result read as a document, as in mock 03: a large title and meta line, a + * pale-blue framing card, numbered arguments that collapse, quotes with a + * left rule and citation chips, and the next steps pinned above the home bar. + * Generate and Attack use these; Brainstorm and Factoids will too. + */ + +const BODY = 15.5; +const PINNED_HEIGHT = 54; + +/** The scrolling page under the header, leaving room for the pinned actions. */ +export function DocumentScreen({ header, children, actions, notice, testID }: { header: ReactNode; children: ReactNode; actions?: ReactNode; notice?: string; testID?: string }) { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const bottom = Math.max(insets.bottom - 6, 14); + return ( + + {header} + + {children} + + {notice ? ( + + {notice} + + ) : null} + {actions ? ( + + {/* The pinned buttons share the width equally, as in the mock, within the document's column on iPad. */} + + {Children.map(unwrapFragment(actions), (action) => (action ? {action} : null))} + + + ) : null} + + ); +} + +function unwrapFragment(node: ReactNode): ReactNode { + return isValidElement<{ children?: ReactNode }>(node) && node.type === Fragment ? node.props.children : node; +} + +export function DocumentTitle({ title, meta }: { title: string; meta?: string }) { + return ( + + + {title} + + {meta ? ( + + {meta} + + ) : null} + + ); +} + +/** The pale-blue card the document opens with. */ +export function FramingCard({ blocks }: { blocks: { label: string; text: string }[] }) { + const { colors } = useTheme(); + return ( + + {blocks.map((block, index) => ( + + + {block.label} + + + {block.text} + + + ))} + + ); +} + +export interface NumberedItem { + key: string; + title: string; + /** What a collapsed item says under its title, such as "2 pieces of evidence". */ + summary: string; + body: ReactNode; +} + +/** + * Numbered items that open one at a time or together. The first is open. An + * open item gets its own card; neighbouring closed items share one, as in the mock. + */ +export function NumberedItems({ items, testID }: { items: NumberedItem[]; testID?: string }) { + const { colors } = useTheme(); + const [open, setOpen] = useState>(() => new Set(items[0] ? [items[0].key] : [])); + const toggle = (key: string) => + setOpen((previous) => { + const next = new Set(previous); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + // Runs of closed items share a card. + const groups: { open: boolean; entries: { item: NumberedItem; n: number }[] }[] = []; + items.forEach((item, index) => { + const isOpen = open.has(item.key); + const last = groups[groups.length - 1]; + if (!isOpen && last && !last.open) last.entries.push({ item, n: index + 1 }); + else groups.push({ open: isOpen, entries: [{ item, n: index + 1 }] }); + }); + + return ( + + {groups.map((group) => ( + + {group.entries.map(({ item, n }, index) => ( + + {index > 0 ? : null} + toggle(item.key)} + testID="document-item" + style={({ pressed }) => [styles.itemHeader, pressed && styles.pressed]}> + + {n} + + + + {item.title} + + {group.open ? null : ( + + {item.summary} + + )} + + + + {group.open ? {item.body} : null} + + ))} + + ))} + + ); +} + +/** Body text that may carry Markdown, as the website renders model output. */ +export function Paragraph({ text, tone = 'secondary' }: { text: string; tone?: 'foreground' | 'secondary' }) { + return ; +} + +/** A labelled line, like the website's "Impact:" rows. */ +export function Labeled({ label, text, italic }: { label: string; text: string; italic?: boolean }) { + return ( + + + {label.replace(/:?$/, ':')}{' '} + + + {text} + + + ); +} + +/** A quote with the mock's left rule, followed by its citation chips. */ +export function Quote({ text, chips = [], onCite, italic }: { text: string; chips?: CitationTarget[]; onCite?: (target: CitationTarget) => void; italic?: boolean }) { + const { colors } = useTheme(); + return ( + + + {text} + {chips.map((chip) => ( + onCite(chip) : undefined}> + {' '} + + + ))} + + + ); +} + +/** The sources behind citation chips, as a row of chips. */ +export function SourceChips({ chips, onCite }: { chips: CitationTarget[]; onCite: (target: CitationTarget) => void }) { + if (chips.length === 0) return null; + return ( + + {chips.map((chip) => ( + } onPress={() => onCite(chip)} /> + ))} + + ); +} + +export function SectionHeading({ title, description }: { title: string; description?: string }) { + return ( + + + {title} + + {description ? ( + + {description} + + ) : null} + + ); +} + +/** A titled part of the document, in the same card style as the arguments. */ +export function DocSection({ title, description, children, testID }: { title: string; description?: string; children: ReactNode; testID?: string }) { + const { colors } = useTheme(); + return ( + + + + {title} + + {description ? ( + + {description} + + ) : null} + + {children} + + ); +} + +/** A small heading inside a section. */ +export function SubHeading({ children }: { children: string }) { + return ( + + {children} + + ); +} + +export function Bullets({ items, marker = '•', color }: { items: string[]; marker?: string; color?: string }) { + const { colors } = useTheme(); + if (items.length === 0) return null; + return ( + + {items.map((item, index) => ( + + + {marker} + + + {item} + + + ))} + + ); +} + +/** A small outlined label, such as a severity or "Needs Source". */ +export function Tag({ label, color, icon }: { label: string; color?: string; icon?: 'alert' }) { + const { colors } = useTheme(); + const tint = color ?? colors.secondary; + return ( + + {icon ? : null} + + {label} + + + ); +} + +export function Divider() { + const { colors } = useTheme(); + return ; +} + +/** A tinted box inside an item, such as the website's "Improved Version". */ +export function Callout({ title, children }: { title: string; children: ReactNode }) { + const { colors } = useTheme(); + return ( + + + {title} + + {children} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 18, paddingTop: 4, gap: 12, width: '100%', maxWidth: 680, alignSelf: 'center' }, + pinned: { position: 'absolute', left: 0, right: 0, alignItems: 'center' }, + pinnedRow: { flexDirection: 'row', gap: 10, width: '100%', maxWidth: 680, paddingHorizontal: 14 }, + pinnedItem: { flex: 1 }, + pressed: { opacity: 0.7 }, + notice: { position: 'absolute', left: 24, right: 24, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 11, alignSelf: 'center', maxWidth: 560 }, + noticeText: { fontSize: 15, lineHeight: 20, textAlign: 'center' }, + titleBlock: { gap: 6, marginBottom: 4 }, + title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, + meta: { fontSize: 15, lineHeight: 20 }, + framing: { borderWidth: 1, borderRadius: radius.card + 1, paddingHorizontal: 16, paddingVertical: 14, gap: 12 }, + framingBlock: { gap: 4 }, + framingLabel: { fontSize: 15 }, + framingText: { fontSize: 15.5, lineHeight: 22.5 }, + items: { gap: 10 }, + itemCard: { borderWidth: 1, borderRadius: radius.card + 1, overflow: 'hidden' }, + itemHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 13, minHeight: 64 }, + itemNumber: { fontSize: 17, minWidth: 14 }, + itemNumberOpen: { alignSelf: 'flex-start', marginTop: 1 }, + itemHeaderText: { flex: 1, gap: 2 }, + itemTitle: { fontSize: 16.5, lineHeight: 22 }, + itemTitleOpen: { fontSize: 17.5, lineHeight: 23 }, + itemSummary: { fontSize: 14.5, lineHeight: 19 }, + itemBody: { paddingLeft: 42, paddingRight: 16, paddingBottom: 16, gap: 10, marginTop: -4 }, + labeled: { fontSize: BODY, lineHeight: 22 }, + italic: { fontStyle: 'italic' }, + quote: { borderLeftWidth: 3.5, paddingLeft: 12, paddingVertical: 2 }, + quoteText: { fontSize: 15, lineHeight: 22 }, + chipRow: { flexGrow: 0, marginHorizontal: -16 }, + chips: { gap: 8, paddingHorizontal: 16 }, + section: { gap: 8, marginTop: 8 }, + sectionHead: { gap: 2, marginHorizontal: 2 }, + sectionHeading: { marginTop: 8 }, + sectionTitle: { fontSize: 19, lineHeight: 25, letterSpacing: -0.2 }, + sectionDescription: { fontSize: 14, lineHeight: 19 }, + sectionCard: { borderWidth: 1, borderRadius: radius.card + 1, padding: 16, gap: 12 }, + subHeading: { fontSize: 14, lineHeight: 19 }, + bullets: { gap: 6 }, + bullet: { flexDirection: 'row', gap: 8 }, + bulletMarker: { fontSize: BODY, lineHeight: 22, minWidth: 12 }, + bulletText: { flex: 1, fontSize: BODY, lineHeight: 22 }, + tag: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start', borderWidth: 1, borderRadius: 7, paddingHorizontal: 7, paddingVertical: 2 }, + tagText: { fontSize: 12.5, lineHeight: 17 }, + divider: { height: 1 }, + callout: { borderWidth: 1, borderRadius: 12, padding: 12, gap: 4 }, + calloutTitle: { fontSize: 14.5 }, +}); diff --git a/apps/mobile/src/components/form-rows.tsx b/apps/mobile/src/components/form-rows.tsx new file mode 100644 index 000000000..88be58e9a --- /dev/null +++ b/apps/mobile/src/components/form-rows.tsx @@ -0,0 +1,125 @@ +import { useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, Switch, View } from 'react-native'; + +import { ActionMenu } from './action-menu'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** An inset group of rows, iOS Settings style. */ +export function FormGroup({ label, children, footer }: { label?: string; children: ReactNode; footer?: string }) { + const { colors } = useTheme(); + return ( + + {label ? ( + + {label} + + ) : null} + {children} + {footer ? ( + + {footer} + + ) : null} + + ); +} + +function RowFrame({ first, children }: { first?: boolean; children: ReactNode }) { + const { colors } = useTheme(); + return {children}; +} + +/** A row that shows the chosen option and opens the native picker sheet. */ +export function SelectRow({ + label, + value, + options, + onChange, + first, + disabled, + testID, +}: { + label: string; + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; + first?: boolean; + disabled?: boolean; + testID?: string; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [open, setOpen] = useState(false); + const chosen = options.find((option) => option.value === value)?.label ?? value; + return ( + <> + setOpen(true)} + testID={testID} + style={({ pressed }) => [pressed && styles.pressed]}> + + + {label} + + + {chosen} + + + + + ({ key: option.value, label: option.label, icon: option.value === value ? 'check' : undefined }))} + cancelLabel={t('common.cancel')} + onClose={() => setOpen(false)} + onSelect={(key) => onChange(key as T)} + /> + + ); +} + +/** A labelled switch with an optional line of explanation. */ +export function SwitchRow({ label, description, value, onChange, first, disabled }: { label: string; description?: string; value: boolean; onChange: (value: boolean) => void; first?: boolean; disabled?: boolean }) { + const { colors } = useTheme(); + return ( + + + {label} + {description ? ( + + {description} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + group: { gap: 7 }, + groupLabel: { fontSize: 14, marginLeft: 4 }, + groupCard: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + footer: { fontSize: 13, lineHeight: 18, marginLeft: 4 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 15, minHeight: 50, paddingVertical: 8 }, + rowLabel: { fontSize: 16.5, flexShrink: 1 }, + rowValue: { flex: 1, fontSize: 16, textAlign: 'right' }, + switchText: { flex: 1, gap: 2 }, + description: { fontSize: 13.5, lineHeight: 18 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index c3c0a30fd..802e979aa 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -36,6 +36,12 @@ export const iconPaths = { list: '', map: '', arrowUp: '', + chevronUp: '', + share: '', + more: '', + download: '', + copy: '', + alert: '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/components/markdown-view.tsx b/apps/mobile/src/components/markdown-view.tsx index de2b0a15c..05193fde8 100644 --- a/apps/mobile/src/components/markdown-view.tsx +++ b/apps/mobile/src/components/markdown-view.tsx @@ -20,13 +20,20 @@ export function MarkdownView({ claims = [], streaming, onCite, + size, + tone, }: { source: string; claims?: string[]; streaming?: boolean; onCite?: (n: number) => void; + /** Body text size; documents use a smaller one than Ask's answers. */ + size?: number; + tone?: 'foreground' | 'secondary'; }) { const { colors } = useTheme(); + const body = size ? { fontSize: size, lineHeight: Math.round(size * 1.47) } : null; + const paragraph = [styles.paragraph, body]; const blocks = useMemo(() => attachCitations(parseMarkdown(source), claims), [source, claims]); const caret = ; @@ -76,14 +83,14 @@ export function MarkdownView({ switch (block.type) { case 'paragraph': return ( - + {inline(block.inline)} {tail} ); case 'heading': return ( - + {inline(block.inline)} {tail} @@ -91,7 +98,7 @@ export function MarkdownView({ case 'quote': return ( - + {inline(block.inline)} {tail} @@ -111,10 +118,10 @@ export function MarkdownView({ {block.items.map((item, itemIndex) => ( - + {block.ordered ? `${block.start + itemIndex}.` : '•'} - + {inline(item, `${itemIndex}:`)} {streaming && index === last && itemIndex === block.items.length - 1 ? caret : null} @@ -127,7 +134,7 @@ export function MarkdownView({ return ( - {blocks.length === 0 && streaming ? {caret} : blocks.map((block, index) => {render(block, index)})} + {blocks.length === 0 && streaming ? {caret} : blocks.map((block, index) => {render(block, index)})} ); } diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts index 64691553a..95349fa22 100644 --- a/apps/mobile/src/components/practice-tools.ts +++ b/apps/mobile/src/components/practice-tools.ts @@ -1,6 +1,6 @@ import type { IconName } from './icon-paths'; -/** The Practice tools, in the website's order. Ask is the first one in the app; the rest follow. */ +/** The Practice tools, in the website's order. Attack and Ask are in the app; the rest follow. */ export const PRACTICE_TOOLS = [ { key: 'attack', icon: 'shield' }, { key: 'ask', icon: 'chat' }, diff --git a/apps/mobile/src/components/prepare-tools.ts b/apps/mobile/src/components/prepare-tools.ts new file mode 100644 index 000000000..3a36b2e34 --- /dev/null +++ b/apps/mobile/src/components/prepare-tools.ts @@ -0,0 +1,11 @@ +import type { IconName } from './icon-paths'; + +/** The Prepare tools, in the website's order. Generate is in the app; the others follow in later updates. */ +export const PREPARE_TOOLS = [ + { key: 'generate', icon: 'sparkles' }, + { key: 'brainstorm', icon: 'bulb' }, + { key: 'factoids', icon: 'book' }, + { key: 'flowchart', icon: 'branch' }, +] as const satisfies readonly { key: string; icon: IconName }[]; + +export type PrepareTool = (typeof PREPARE_TOOLS)[number]['key']; diff --git a/apps/mobile/src/components/tool-form.tsx b/apps/mobile/src/components/tool-form.tsx new file mode 100644 index 000000000..9dce46cda --- /dev/null +++ b/apps/mobile/src/components/tool-form.tsx @@ -0,0 +1,120 @@ +import { useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { TextButton } from './buttons'; +import { Text } from './text'; + +import type { AIErrorKind } from '@/lib/ai-request'; +import { useDebates } from '@/lib/debate-context'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** The tool's large title and the line under it. */ +export function ToolTitle({ title, subtitle }: { title: string; subtitle: string }) { + return ( + + + {title} + + + {subtitle} + + + ); +} + +/** Where new work will be saved, as the website's CurrentDebateStrip says it. */ +export function WorkingIn() { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { current, oneOff } = useDebates(); + return ( + + {oneOff ? ( + + {t('debate.oneOffStrip')} + + ) : current ? ( + + {`${t('debate.workingIn')} `} + + {current.motion} + + + ) : ( + + {t('debate.noCurrentStrip')} + + )} + + ); +} + +const PLAN_ERRORS = new Set(['trialExpired', 'limitReached', 'modelRestricted']); + +/** Why a request failed, in the tool's words, with the way forward. */ +export function ToolError({ title, error, onRetry }: { title: string; error: AIErrorKind | 'debate'; onRetry: () => void }) { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + return ( + + + {title} + + + {error === 'debate' ? t('debate.saveFailed') : t(`mobile.results.errors.${error}`)} + + + {error !== 'debate' && PLAN_ERRORS.has(error) ? ( + router.navigate('/you')} /> + ) : ( + + )} + + + ); +} + +/** A calm progress state that names what is happening, cycling the website's messages. */ +export function ToolProgress({ messages, detail, onCancel }: { messages: string[]; detail?: string; onCancel: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [index, setIndex] = useState(0); + useEffect(() => { + const timer = setInterval(() => setIndex((value) => (value + 1) % messages.length), 3000); + return () => clearInterval(timer); + }, [messages.length]); + return ( + + + + {messages[index]} + + {detail ? ( + + {detail} + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + titleBlock: { gap: 4, marginBottom: 2 }, + title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, + subtitle: { fontSize: 16, lineHeight: 22 }, + strip: { borderWidth: 1, borderRadius: radius.field, paddingHorizontal: 14, paddingVertical: 10 }, + dashed: { borderStyle: 'dashed' }, + stripText: { fontSize: 14.5, lineHeight: 20 }, + error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, + errorTitle: { fontSize: 16 }, + errorBody: { fontSize: 15, lineHeight: 21 }, + errorActions: { flexDirection: 'row', gap: 20 }, + progress: { borderWidth: 1, borderStyle: 'dashed', borderRadius: radius.card + 2, paddingVertical: 36, paddingHorizontal: 20, alignItems: 'center', gap: 12, marginTop: 8 }, + progressText: { fontSize: 18, lineHeight: 24, textAlign: 'center' }, + progressDetail: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, +}); diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx new file mode 100644 index 000000000..d8811744f --- /dev/null +++ b/apps/mobile/src/components/work-row.tsx @@ -0,0 +1,79 @@ +import type { Href } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { IconTile } from './card'; +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; +import { Text } from './text'; + +import { formatWhen } from '@/lib/dates'; +import type { DebateItem, DebateItemKind } from '@/lib/debates'; +import { useTheme } from '@/theme/use-theme'; + +export const KIND_ICONS: Record = { + case: 'sparkles', + attack: 'shield', + brainstorm: 'bulb', + factoids: 'book', + chat: 'chat', + flowchart: 'branch', + crossfire: 'help', + sparring: 'swords', +}; + +/** The tab a saved result's document goes back to. */ +export type OpenedFrom = 'debate' | 'prepare' | 'practice'; + +/** + * Where a saved item opens. Cases and attacks open read-only as documents; + * the Prepare tools not in the app yet open their "on its way" screen, and + * Practice work opens the Practice tab. + */ +export function workHref(item: DebateItem, from: OpenedFrom): Href { + switch (item.kind) { + case 'case': + return { pathname: '/case/[id]', params: { id: item.id, from } }; + case 'attack': + return { pathname: '/attack/[id]', params: { id: item.id, from } }; + case 'brainstorm': + case 'factoids': + case 'flowchart': + return { pathname: '/coming/[tool]', params: { tool: item.kind } }; + default: + return '/practice'; + } +} + +/** One saved result or round, with its kind and when it was made. */ +export function WorkRow({ item, first, onPress }: { item: DebateItem; first: boolean; onPress: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + [styles.row, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + + {item.title || t(`debate.kinds.${item.kind}`)} + + + {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} + + + + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + rowText: { flex: 1, gap: 2 }, + rowTitle: { fontSize: 16 }, + rowMeta: { fontSize: 14 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts new file mode 100644 index 000000000..f6af55746 --- /dev/null +++ b/apps/mobile/src/lib/ai-request.ts @@ -0,0 +1,141 @@ +import { parseSourceAssessments, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; +import { fetch } from 'expo/fetch'; + +import { supabase } from './supabase'; +import { supabaseProject } from './supabase-config'; + +/** + * One request to an AI Edge Function, sent the way the website's AI client + * (src/lib/ai/client.ts) sends it: the same headers, the 200 s timeout, the + * caller's abort, and its errors sorted into the kinds the screens explain. + */ + +export const REQUEST_TIMEOUT_MS = 200 * 1000; +const MAX_REQUEST_BODY_BYTES = 190_000; +export const PROVIDER = 'openrouter'; + +export type AIErrorKind = + | 'auth' + | 'trialExpired' + | 'limitReached' + | 'modelRestricted' + | 'rateLimit' + | 'network' + | 'timeout' + | 'tooLarge' + | 'debateMissing' + | 'failed'; + +export class AIError extends Error { + constructor(readonly kind: AIErrorKind, message: string) { + super(message); + this.name = 'AIError'; + } +} + +export function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +export function abortError(): Error { + const error = new Error('Aborted'); + error.name = 'AbortError'; + return error; +} + +export function utf8Length(value: string): number { + let bytes = 0; + for (const char of value) { + const code = char.codePointAt(0)!; + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + } + return bytes; +} + +async function accessToken(): Promise { + const { data } = await supabase.auth.getSession(); + const token = data.session?.access_token; + if (!token) throw new AIError('auth', 'Please sign in to use AI features'); + return token; +} + +export function errorFrom(status: number | undefined, payload: Record | null, fallback: string): AIError { + const message = typeof payload?.error === 'string' ? payload.error : typeof payload?.message === 'string' ? payload.message : fallback; + const code = typeof payload?.code === 'string' ? payload.code : undefined; + if (status === 401) return new AIError('auth', message); + if (code === 'upgrade_required') return new AIError('modelRestricted', message); + if (status === 402 || code === 'trial_expired' || code === 'limit_reached') { + return new AIError(code === 'trial_expired' ? 'trialExpired' : 'limitReached', message); + } + if (status === 403) return /model|upgrade/i.test(message) ? new AIError('modelRestricted', message) : new AIError('auth', message); + if (status === 404) return new AIError('debateMissing', message); + if (status === 429 || /rate limit|limit exceeded/i.test(message)) return new AIError('rateLimit', message); + return new AIError('failed', message); +} + +export async function payloadOf(response: { json: () => Promise }): Promise | null> { + const payload: unknown = await response.json().catch((): null => null); + return payload && typeof payload === 'object' && !Array.isArray(payload) ? (payload as Record) : null; +} + +export type FunctionResponse = Awaited>; + +/** One POST with the website's headers, a 200 s timeout and the caller's abort. */ +export async function postFunction(path: string, body: Record, signal: AbortSignal | undefined, run: (response: FunctionResponse) => Promise): Promise { + // The deadline and the caller's abort start before the session read, so + // leaving the screen while the token refreshes never sends the request. + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const abortForCaller = () => controller.abort(); + signal?.addEventListener('abort', abortForCaller); + if (signal?.aborted) controller.abort(); + try { + if (controller.signal.aborted) throw abortError(); + const token = await accessToken(); + if (controller.signal.aborted) throw abortError(); + const json = JSON.stringify(body); + if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AIError('tooLarge', 'Request is too large'); + const response = await fetch(`${supabaseProject.url}/functions/v1/${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, apikey: supabaseProject.anonKey, 'Content-Type': 'application/json' }, + body: json, + signal: controller.signal, + }).catch((cause: unknown) => { + if (controller.signal.aborted) throw cause; + throw new AIError('network', cause instanceof Error ? cause.message : 'Network request failed'); + }); + if (!response.ok) throw errorFrom(response.status, await payloadOf(response), `Request failed (${response.status})`); + return await run(response); + } catch (error) { + if (signal?.aborted) throw abortError(); + if (controller.signal.aborted) throw new AIError('timeout', 'The request took too long'); + throw error; + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', abortForCaller); + } +} + +/** + * The `output` of a whole answer from the ai function. A 200 response can + * still carry an error, as the website's callSupabaseAI handles. + */ +export async function functionOutput(response: FunctionResponse): Promise<{ output: unknown; data: Record }> { + const data = await payloadOf(response); + if (typeof data?.error === 'string') throw errorFrom(undefined, data, data.error); + if (!data?.output) throw new AIError('failed', 'The answer was missing'); + return { output: data.output, data }; +} + +/** What a whole answer says besides its output, which the website acts on for every feature (callSupabaseAI). */ +export interface ResponseExtras { + /** The server shortened the input to fit; the website tells the student. */ + truncated: boolean; + /** Rankings of the Library sources the answer used, saved onto those sources. */ + assessments: SourceAssessmentUpdate[]; +} + +export function responseExtras(data: Record): ResponseExtras { + const meta = data.meta && typeof data.meta === 'object' ? (data.meta as { truncated?: unknown }) : null; + return { truncated: meta?.truncated === true, assessments: parseSourceAssessments(data.sourceAssessments) }; +} diff --git a/apps/mobile/src/lib/ask.ts b/apps/mobile/src/lib/ask.ts index b1174cdc3..d912a4f68 100644 --- a/apps/mobile/src/lib/ask.ts +++ b/apps/mobile/src/lib/ask.ts @@ -1,11 +1,9 @@ import { AskOutputSchema, type AskOutput } from '@debatelab/core/ai/schemas/ask'; import { consumeSSEChunk, type ParsedSSEEvent } from '@debatelab/core/ai/sse'; import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; -import { fetch } from 'expo/fetch'; -import type { AskConfig } from './ask-config'; -import { supabase } from './supabase'; -import { supabaseProject } from './supabase-config'; +import { abortError, AIError, errorFrom, functionOutput, postFunction, PROVIDER, responseExtras, utf8Length, type ResponseExtras } from './ai-request'; +import type { FeatureConfig } from './feature-config'; /** * Ask requests, sent the way the website sends them (src/lib/ai/streaming.ts @@ -15,13 +13,10 @@ import { supabaseProject } from './supabase-config'; * function, as on the website. */ -export const REQUEST_TIMEOUT_MS = 200 * 1000; const MAX_HISTORY_ITEMS = 8; const MAX_HISTORY_CONTENT_BYTES = 6_000; const MAX_HISTORY_TOTAL_BYTES = 48_000; const MAX_MESSAGE_BYTES = 80_000; -const MAX_REQUEST_BODY_BYTES = 190_000; -const PROVIDER = 'openrouter'; export interface AskOptions { citationMode: boolean; @@ -39,44 +34,15 @@ export interface AskRequest { message: string; history: HistoryMessage[]; options: AskOptions; - config: AskConfig; + config: FeatureConfig; language: string; /** Absent for one-off work. */ debateContext?: DebateContextRequest; } -export type AskErrorKind = - | 'auth' - | 'trialExpired' - | 'limitReached' - | 'modelRestricted' - | 'rateLimit' - | 'network' - | 'timeout' - | 'tooLarge' - | 'debateMissing' - | 'failed'; - -export class AskError extends Error { - constructor(readonly kind: AskErrorKind, message: string) { - super(message); - this.name = 'AskError'; - } -} - /** The answer kinds that fail the same way however they are asked, so retrying without streaming cannot help. */ export function isFinalAskError(error: unknown): boolean { - return error instanceof AskError && error.kind !== 'failed' && error.kind !== 'network'; -} - -export function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError'; -} - -function abortError(): Error { - const error = new Error('Aborted'); - error.name = 'AbortError'; - return error; + return error instanceof AIError && error.kind !== 'failed' && error.kind !== 'network'; } /** Streaming serves plain questions only, as on the website (isAskStreamingSupported). */ @@ -84,15 +50,6 @@ export function canStream(options: AskOptions): boolean { return !(options.citationMode || options.webSearch || options.prosConsMode || options.argumentMapMode); } -function utf8Length(value: string): number { - let bytes = 0; - for (const char of value) { - const code = char.codePointAt(0)!; - bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; - } - return bytes; -} - /** Cuts at a whole character, like the website's truncateUtf8. */ function truncateUtf8(value: string, maxBytes: number): string { let bytes = 0; @@ -122,7 +79,7 @@ export function boundHistory(history: HistoryMessage[]): HistoryMessage[] { } function requestInput(request: AskRequest) { - if (!isMessageWithinBudget(request.message)) throw new AskError('tooLarge', 'Message is too large'); + if (!isMessageWithinBudget(request.message)) throw new AIError('tooLarge', 'Message is too large'); return { message: request.message, history: boundHistory(request.history), @@ -135,78 +92,16 @@ function requestInput(request: AskRequest) { }; } -async function accessToken(): Promise { - const { data } = await supabase.auth.getSession(); - const token = data.session?.access_token; - if (!token) throw new AskError('auth', 'Please sign in to use AI features'); - return token; -} - -function errorFrom(status: number | undefined, payload: Record | null, fallback: string): AskError { - const message = typeof payload?.error === 'string' ? payload.error : typeof payload?.message === 'string' ? payload.message : fallback; - const code = typeof payload?.code === 'string' ? payload.code : undefined; - if (status === 401) return new AskError('auth', message); - if (code === 'upgrade_required') return new AskError('modelRestricted', message); - if (status === 402 || code === 'trial_expired' || code === 'limit_reached') { - return new AskError(code === 'trial_expired' ? 'trialExpired' : 'limitReached', message); - } - if (status === 403) return /model|upgrade/i.test(message) ? new AskError('modelRestricted', message) : new AskError('auth', message); - if (status === 404) return new AskError('debateMissing', message); - if (status === 429 || /rate limit|limit exceeded/i.test(message)) return new AskError('rateLimit', message); - return new AskError('failed', message); -} - -async function payloadOf(response: { json: () => Promise }): Promise | null> { - const payload: unknown = await response.json().catch((): null => null); - return payload && typeof payload === 'object' && !Array.isArray(payload) ? (payload as Record) : null; -} - /** The stream's final event names the model that answered; the website records it on the output. */ function parseOutput(output: unknown, resolvedModel?: string): AskOutput { const enriched = resolvedModel && output && typeof output === 'object' && !Array.isArray(output) ? { ...output, model: resolvedModel, provider: PROVIDER } : output; const parsed = AskOutputSchema.safeParse(enriched); - if (!parsed.success) throw new AskError('failed', 'The answer did not match the expected shape'); + if (!parsed.success) throw new AIError('failed', 'The answer did not match the expected shape'); return parsed.data; } -/** One POST with the website's headers, a 200 s timeout and the caller's abort. */ -async function post(path: string, body: Record, signal: AbortSignal | undefined, run: (response: Awaited>) => Promise): Promise { - // The deadline and the caller's abort start before the session read, so - // leaving Ask while the token refreshes never sends the request. - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - const abortForCaller = () => controller.abort(); - signal?.addEventListener('abort', abortForCaller); - if (signal?.aborted) controller.abort(); - try { - if (controller.signal.aborted) throw abortError(); - const token = await accessToken(); - if (controller.signal.aborted) throw abortError(); - const json = JSON.stringify(body); - if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AskError('tooLarge', 'Request is too large'); - const response = await fetch(`${supabaseProject.url}/functions/v1/${path}`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, apikey: supabaseProject.anonKey, 'Content-Type': 'application/json' }, - body: json, - signal: controller.signal, - }).catch((cause: unknown) => { - if (controller.signal.aborted) throw cause; - throw new AskError('network', cause instanceof Error ? cause.message : 'Network request failed'); - }); - if (!response.ok) throw errorFrom(response.status, await payloadOf(response), `Request failed (${response.status})`); - return await run(response); - } catch (error) { - if (signal?.aborted) throw abortError(); - if (controller.signal.aborted) throw new AskError('timeout', 'The request took too long'); - throw error; - } finally { - clearTimeout(timeout); - signal?.removeEventListener('abort', abortForCaller); - } -} - /** Streams an answer from ai-stream, calling onDelta with each piece of text. */ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: string) => void; signal?: AbortSignal }): Promise { const body = { @@ -218,8 +113,8 @@ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: strin temperature: request.config.temperature, reasoningEffort: request.config.reasoningEffort, }; - return post('ai-stream', body, handlers.signal, async (response) => { - if (!response.body) throw new AskError('failed', 'The answer did not stream'); + return postFunction('ai-stream', body, handlers.signal, async (response) => { + if (!response.body) throw new AIError('failed', 'The answer did not stream'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; @@ -256,7 +151,7 @@ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: strin consume(parsed.events); } consume(consumeSSEChunk('', `${buffer}\n\n`).events); - if (!output) throw new AskError('failed', 'Streaming request ended before a final response was received'); + if (!output) throw new AIError('failed', 'Streaming request ended before a final response was received'); return output; } finally { handlers.signal?.removeEventListener('abort', cancel); @@ -265,8 +160,12 @@ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: strin }); } -/** Asks the ai Edge Function for a whole answer, with the options the stream cannot serve. */ -export function askOnce(request: AskRequest, signal?: AbortSignal): Promise { +/** + * Asks the ai Edge Function for a whole answer, with the options the stream + * cannot serve. onExtras receives what the answer says besides its output, + * which the website acts on for whole answers only. + */ +export function askOnce(request: AskRequest, signal?: AbortSignal, onExtras?: (extras: ResponseExtras) => void): Promise { const body = { feature: 'ask', input: requestInput(request), @@ -278,10 +177,10 @@ export function askOnce(request: AskRequest, signal?: AbortSignal): Promise { - const data = await payloadOf(response); - if (typeof data?.error === 'string') throw errorFrom(undefined, data, data.error); - if (!data?.output) throw new AskError('failed', 'The answer was missing'); - return parseOutput(data.output); + return postFunction('ai', body, signal, async (response) => { + const { output, data } = await functionOutput(response); + const result = parseOutput(output); + onExtras?.(responseExtras(data)); + return result; }); } diff --git a/apps/mobile/src/lib/auth.tsx b/apps/mobile/src/lib/auth.tsx index ec0083080..85ab720d0 100644 --- a/apps/mobile/src/lib/auth.tsx +++ b/apps/mobile/src/lib/auth.tsx @@ -1,7 +1,8 @@ import type { Session } from '@supabase/supabase-js'; -import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { getInstallId, sessionStorage } from './device-storage'; +import { setSessionOwner } from './session-memory'; import { supabase } from './supabase'; import { sessionStorageKey } from './supabase-config'; @@ -76,7 +77,13 @@ async function signOutError(scope: 'global' | 'local'): Promise { } export function AuthProvider({ children }: { children: ReactNode }) { - const [session, setSession] = useState(null); + const [session, setSessionState] = useState(null); + // The session memory is cleared before an account change renders, so no + // screen ever shows the previous account's forms or results. + const setSession = useCallback((next: Session | null) => { + setSessionOwner(next?.user.id ?? null); + setSessionState(next); + }, []); const [loading, setLoading] = useState(true); const [settled, setSettled] = useState(false); // Counts auth changes, so a storage read that lands after a newer change is dropped. @@ -107,7 +114,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setSettled(true); }); return () => data.subscription.unsubscribe(); - }, []); + }, [setSession]); const value = useMemo(() => ({ session, @@ -155,7 +162,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setSession(null); setLoading(false); }, - }), [session, loading, settled]); + }), [session, loading, settled, setSession]); return {children}; } diff --git a/apps/mobile/src/lib/back-label.ts b/apps/mobile/src/lib/back-label.ts new file mode 100644 index 000000000..c21bf44b5 --- /dev/null +++ b/apps/mobile/src/lib/back-label.ts @@ -0,0 +1,7 @@ +import type { TFunction } from 'i18next'; + +/** The back button names the tab a document was opened from, as mock 03's "Prepare". */ +export function backLabel(t: TFunction, from: string | undefined, fallback: 'debate' | 'prepare' | 'practice'): string { + const tab = from === 'debate' || from === 'prepare' || from === 'practice' ? from : from ? null : fallback; + return tab ? t(`mobile.tabs.${tab}`) : t('common.back'); +} diff --git a/apps/mobile/src/lib/confirm.ts b/apps/mobile/src/lib/confirm.ts index a12483994..226e6ef97 100644 --- a/apps/mobile/src/lib/confirm.ts +++ b/apps/mobile/src/lib/confirm.ts @@ -9,3 +9,18 @@ export function confirmDestructive(options: { title: string; message: string; co ], { cancelable: true, onDismiss: () => resolve(false) }); }); } + +/** A native alert with one button, for a short outcome the student should notice. */ +export function showNotice(title: string, message?: string) { + Alert.alert(title, message); +} + +/** iOS's text prompt. Resolves the entered text, or null when cancelled. */ +export function promptText(options: { title: string; message: string; defaultValue: string; confirm: string; cancel: string }): Promise { + return new Promise((resolve) => { + Alert.prompt(options.title, options.message, [ + { text: options.cancel, style: 'cancel', onPress: () => resolve(null) }, + { text: options.confirm, onPress: (value?: string) => resolve(value ?? '') }, + ], 'plain-text', options.defaultValue); + }); +} diff --git a/apps/mobile/src/lib/confirm.web.ts b/apps/mobile/src/lib/confirm.web.ts index 2aeeea491..ca5014496 100644 --- a/apps/mobile/src/lib/confirm.web.ts +++ b/apps/mobile/src/lib/confirm.web.ts @@ -2,3 +2,11 @@ export function confirmDestructive(options: { title: string; message: string; confirm: string; cancel: string }): Promise { return Promise.resolve(window.confirm(`${options.title}\n\n${options.message}`)); } + +export function showNotice(title: string, message?: string) { + window.alert(message ? `${title}\n\n${message}` : title); +} + +export function promptText(options: { title: string; message: string; defaultValue: string; confirm: string; cancel: string }): Promise { + return Promise.resolve(window.prompt(`${options.title}\n\n${options.message}`, options.defaultValue)); +} diff --git a/apps/mobile/src/lib/ask-context.ts b/apps/mobile/src/lib/context-request.ts similarity index 86% rename from apps/mobile/src/lib/ask-context.ts rename to apps/mobile/src/lib/context-request.ts index 7f7d85af3..bbbf061e4 100644 --- a/apps/mobile/src/lib/ask-context.ts +++ b/apps/mobile/src/lib/context-request.ts @@ -5,6 +5,7 @@ import { MAX_CONTEXT_OUTPUTS, MAX_CONTEXT_SOURCES, selectDefaultOutputs, + type DebateContextFeature, type DebateContextOutputKind, type DebateContextRequest, type DebateContextSide, @@ -15,9 +16,9 @@ import { import { supabase } from './supabase'; /** - * The Current Debate material an Ask request sends: IDs only, picked the way - * the website's planDebateContext (src/lib/debate/context.ts) picks them with - * no Review choices. Pins come from debate_context_pins, as on the website; + * The Current Debate material a request sends: IDs only, picked the way the + * website's planDebateContext (src/lib/debate/context.ts) picks them with no + * Review choices. Pins come from debate_context_pins, as on the website; * if they cannot be read, the automatic selection is used. The server loads * and checks the text. */ @@ -35,9 +36,6 @@ const RESULT_KINDS = Object.keys(RESULT_TABLES) as ResultKind[]; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -// Ask prepares no side, so every saved result matches it. -const ASK_SIDE: DebateContextSide = 'both'; - function sideOf(value: unknown): DebateContextSide | null { return value === 'pro' || value === 'con' || value === 'both' || value === 'neutral' ? value : null; } @@ -73,8 +71,18 @@ async function outputData(userId: string, ref: DebateOutputRef): Promise { +/** + * `side` is the side the request prepares, as the website's requestSide + * picks it. `excludeChatId` is the chat open in Ask: it is already the + * conversation. + */ +export async function buildDebateContext( + userId: string, + debateId: string, + feature: DebateContextFeature, + side: DebateContextSide, + excludeChatId: string | null = null, +): Promise { const [sources, pins, sparring, ...results] = await Promise.all([ supabase.from('sources').select('id,is_active').eq('user_id', userId).eq('debate_id', debateId).order('created_at', { ascending: false }), supabase.from('debate_context_pins').select('item_kind,item_id').eq('user_id', userId).eq('debate_id', debateId), @@ -106,14 +114,14 @@ export async function buildAskDebateContext(userId: string, debateId: string, ex candidates.push({ kind: 'sparring', id: row.id, side: sideOf(row.user_side), createdAt: new Date(row.updated_at).getTime(), pinned: pinnedKeys.has(`sparring:${row.id}`) }); } - const sent = selectDefaultOutputs(candidates, 'ask', ASK_SIDE).slice(0, MAX_CONTEXT_OUTPUTS); + const sent = selectDefaultOutputs(candidates, feature, side).slice(0, MAX_CONTEXT_OUTPUTS); // Results with nothing usable yet, such as an unjudged round, are not sent. const digests = await Promise.all(sent.map(async (ref) => ({ ...ref, digest: digestOutput(ref.kind, await outputData(userId, ref)) }))); const empty = new Set(boundOutputDigests(digests).empty.map((item) => `${item.kind}:${item.id}`)); return { debateId, - side: ASK_SIDE, + side, sourceIds, outputs: sent.filter((ref) => !empty.has(`${ref.kind}:${ref.id}`)), }; diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index d3c63de72..97fa167b9 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -192,6 +192,25 @@ export interface DebateItem { createdAt: number; } +async function fetchResultRows(userId: string, debateId: string, table: ResultTable, sides: string[] | null, limit: number): Promise { + let query = supabase + .from(table) + .select('id,title,created_at') + .eq('user_id', userId) + .eq('debate_id', debateId); + if (sides) query = query.in('side', sides); + const { data, error } = await query.order('created_at', { ascending: false }).limit(limit); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row): DebateItem => ({ + id: row.id, + kind: KIND_BY_TABLE[table], + title: row.title, + createdAt: new Date(row.created_at).getTime(), + })); +} + +const newestFirst = (lists: DebateItem[][], limit: number) => lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); + /** The newest saved results and rounds for one side of a debate, like the website's side lists. */ export async function fetchRecentWork( userId: string, @@ -200,23 +219,7 @@ export async function fetchRecentWork( limit: number, ): Promise { const lists = await Promise.all([ - ...RESULT_TABLES.map(async (table) => { - const { data, error } = await supabase - .from(table) - .select('id,title,created_at') - .eq('user_id', userId) - .eq('debate_id', debateId) - .in('side', SIDES_FOR[side]) - .order('created_at', { ascending: false }) - .limit(limit); - if (error) throw new Error(error.message); - return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row): DebateItem => ({ - id: row.id, - kind: KIND_BY_TABLE[table], - title: row.title, - createdAt: new Date(row.created_at).getTime(), - })); - }), + ...RESULT_TABLES.map((table) => fetchResultRows(userId, debateId, table, SIDES_FOR[side], limit)), (async () => { // The website lists a round under its motion and the side the student spoke, ordered by its last update. const { data, error } = await supabase @@ -236,5 +239,14 @@ export async function fetchRecentWork( })); })(), ]); - return lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); + return newestFirst(lists, limit); +} + +/** The Prepare tools' tables. */ +const PREPARE_TABLES = ['cases', 'brainstorms', 'factoids', 'flowcharts'] as const satisfies readonly ResultTable[]; + +/** A debate's saved Prepare results for both sides, newest first, for the Prepare tab. */ +export async function fetchPrepareWork(userId: string, debateId: string, limit: number): Promise { + const lists = await Promise.all(PREPARE_TABLES.map((table) => fetchResultRows(userId, debateId, table, null, limit))); + return newestFirst(lists, limit); } diff --git a/apps/mobile/src/lib/export-html.ts b/apps/mobile/src/lib/export-html.ts new file mode 100644 index 000000000..bb07b6411 --- /dev/null +++ b/apps/mobile/src/lib/export-html.ts @@ -0,0 +1,126 @@ +import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; + +type Translate = (key: string, options?: Record) => string; + +/** + * The PDF pages for expo-print, as HTML. They carry the same sections and + * labels as the website's PDF export (src/lib/pdf-export-utils.ts) and keep + * its file names, in DebateLab's colours. + */ + +function escape(value: string | undefined | null): string { + return (value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!); +} + +const STYLE = ` + @page { margin: 18mm 16mm; } + body { font-family: -apple-system, 'Inter', 'Helvetica Neue', Arial, sans-serif; color: #0f172a; font-size: 11pt; line-height: 1.5; margin: 0; } + header { border-bottom: 3px solid #0284c7; padding-bottom: 10px; margin-bottom: 18px; } + h1 { font-size: 20pt; line-height: 1.2; margin: 0 0 4px; letter-spacing: -0.02em; } + .meta { color: #64748b; font-size: 10pt; } + h2 { font-size: 13pt; margin: 22px 0 8px; padding-bottom: 4px; border-bottom: 1px solid #e2e8f0; color: #0f172a; } + h3 { font-size: 11pt; margin: 12px 0 4px; } + p { margin: 0 0 8px; } + .label { font-weight: 700; color: #475569; } + .framing { background: #e0f2fe; border: 1px solid #bae6fd; border-radius: 10px; padding: 10px 14px; margin-bottom: 14px; color: #0c4a6e; } + .framing .label { color: #075985; } + .block { border-left: 3px solid #0284c7; padding: 2px 0 2px 12px; margin: 0 0 14px; break-inside: avoid; } + .block h3 { margin-top: 0; } + .number { color: #0284c7; margin-right: 6px; } + ul { margin: 0 0 8px 18px; padding: 0; } + li { margin-bottom: 3px; } + .tag { display: inline-block; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0 6px; font-size: 9pt; font-weight: 700; margin-right: 4px; } + .critical { color: #dc2626; border-color: #dc2626; } + .major { color: #d97706; border-color: #d97706; } + .minor { color: #0284c7; border-color: #0284c7; } + footer { margin-top: 26px; color: #94a3b8; font-size: 9pt; text-align: center; } +`; + +function page(title: string, meta: string, body: string, t: Translate): string { + return `${escape(title)}

${escape(title)}

${escape(meta)}
${body}
${escape(t('export.generatedBy'))}
`; +} + +const paragraph = (label: string, text: string) => `

${escape(label)}: ${escape(text)}

`; +const bullets = (items: string[]) => (items.length ? `
    ${items.map((item) => `
  • ${escape(item)}
  • `).join('')}
` : ''); + +export function caseExportFileName(result: GenerateOutput): string { + const side = result.metadata?.side === 'pro' ? 'pro' : result.metadata?.side === 'con' ? 'con' : 'unknown'; + return `DebateCase_${side}.pdf`; +} + +export function attackExportFileName(result: AttackOutput): string { + return `AttackAnalysis_${result.overallAssessment?.score || 0}.pdf`; +} + +export function caseExportHtml(result: GenerateOutput, t: Translate): string { + const side = result.metadata?.side === 'pro' ? t('export.proposition') : result.metadata?.side === 'con' ? t('export.opposition') : t('export.unknown'); + const format = result.metadata?.format?.toUpperCase() || t('export.standard'); + const title = result.metadata?.motion || t('export.debateCase'); + const parts: string[] = []; + + parts.push(`

${escape(t('export.thesis'))}: ${escape(result.caseStructure?.thesis || t('export.noThesis'))}

${ + result.caseStructure?.framework ? `

${escape(t('export.framework'))}: ${escape(result.caseStructure.framework)}

` : '' + }
`); + if (result.exordio && (result.exordio.hook || result.exordio.transition)) { + parts.push(`

${escape(t('export.exordio'))}

`); + if (result.exordio.hook) parts.push(paragraph(t('export.hook'), result.exordio.hook)); + if (result.exordio.transition) parts.push(paragraph(t('export.transition'), result.exordio.transition)); + } + if (result.caseStructure?.burdenOfProof) parts.push(paragraph(t('export.burdenOfProof'), result.caseStructure.burdenOfProof)); + if (result.caseStructure?.keyDefinitions?.length) parts.push(`

${escape(t('export.keyDefinitions'))}

${bullets(result.caseStructure.keyDefinitions)}`); + + parts.push(`

${escape(t('export.arguments'))}

`); + if (!result.contentions?.length) parts.push(`

${escape(t('export.noArguments'))}

`); + result.contentions?.forEach((c, i) => { + parts.push(`

${escape(t('export.contentionNumber', { number: i + 1 }))}${escape(c.claim)}

${ + c.reasoning ? paragraph(t('export.reasoning'), c.reasoning) : '' + }${c.evidence ? paragraph(t('export.evidence'), c.evidence) : ''}${c.impact ? paragraph(t('export.impact'), c.impact) : ''}
`); + }); + + parts.push(`

${escape(t('export.predictedOpponent'))}

`); + parts.push(result.predictedOpponentCase?.likelyArguments?.length ? bullets(result.predictedOpponentCase.likelyArguments) : `

${escape(t('export.noOpponentArgs'))}

`); + + parts.push(`

${escape(t('export.counters'))}

`); + if (!result.counters?.length) parts.push(`

${escape(t('export.noCounters'))}

`); + result.counters?.forEach((c) => { + parts.push(`

${escape(t('export.target'))}: ${escape(c.targetArgument)}

${ + c.counterpoint ? paragraph(t('export.counter'), c.counterpoint) : '' + }${c.rebuttal ? paragraph(t('export.rebuttal'), c.rebuttal) : ''}
`); + }); + + return page(title, `${side} • ${format} ${t('export.format')}`, parts.join(''), t); +} + +export function attackExportHtml(result: AttackOutput, t: Translate): string { + const score = result.overallAssessment?.score || 0; + const parts: string[] = []; + parts.push(`

${escape(t('export.assessmentSummary'))}

${escape(result.overallAssessment?.summary || t('export.noSummary'))}

`); + if (result.overallAssessment?.strengthAreas?.length) { + parts.push(`

${escape(t('export.strengthAreas'))}

${bullets(result.overallAssessment.strengthAreas)}`); + } + parts.push(`

${escape(t('export.weaknesses'))}

`); + if (!result.weaknesses?.length) parts.push(`

${escape(t('export.noWeaknesses'))}

`); + result.weaknesses?.forEach((w, i) => { + const severity = ['critical', 'major', 'minor'].includes(w.severity) ? w.severity : 'minor'; + parts.push(`

${i + 1}.${escape(w.severity?.toUpperCase())}${escape(w.type)} ${escape(w.description)}

${ + w.affectedQuote ? `

"${escape(w.affectedQuote)}"

` : '' + }${paragraph(t('export.whyWeak'), w.whyWeak || '')}${paragraph(t('export.howOpponentAttacks'), w.howOpponentAttacks || '')}${paragraph(t('export.improvedVersion'), w.improvedVersion || '')}
`); + }); + parts.push(`

${escape(t('export.fallacies'))}

`); + if (!result.fallaciesDetected?.length) parts.push(`

${escape(t('export.noFallacies'))}

`); + result.fallaciesDetected?.forEach((f) => { + parts.push(`

${escape(f.name)}

${paragraph(t('export.location'), f.location)}

${escape(f.explanation)}

`); + }); + parts.push(`

${escape(t('export.crossExQuestions'))}

`); + if (!result.crossExQuestions?.length) parts.push(`

${escape(t('export.noCrossEx'))}

`); + result.crossExQuestions?.forEach((q, i) => { + parts.push(`

Q${i + 1}: ${escape(q.question)}

${paragraph(t('export.purpose'), q.purpose)}${paragraph(t('export.expectedTrap'), q.expectedTrap)}
`); + }); + if (result.weighingAnalysis) { + parts.push(`

${escape(t('export.weighingAnalysis'))}

${escape(result.weighingAnalysis.currentWeighing || t('export.noWeighingAnalysis'))}

`); + if (result.weighingAnalysis.missingComparisons?.length) parts.push(`

${escape(t('export.missingComparisons'))}

${bullets(result.weighingAnalysis.missingComparisons)}`); + if (result.weighingAnalysis.improvementSuggestions?.length) parts.push(`

${escape(t('export.improvementSuggestions'))}

${bullets(result.weighingAnalysis.improvementSuggestions)}`); + } + return page(t('export.caseAttack'), `${t('export.overallScore')}: ${score}/100`, parts.join(''), t); +} diff --git a/apps/mobile/src/lib/export.ts b/apps/mobile/src/lib/export.ts new file mode 100644 index 000000000..839e6544e --- /dev/null +++ b/apps/mobile/src/lib/export.ts @@ -0,0 +1,26 @@ +import * as Clipboard from 'expo-clipboard'; +import { File, Paths } from 'expo-file-system'; +import * as Print from 'expo-print'; +import * as Sharing from 'expo-sharing'; +import { Share } from 'react-native'; + +/** + * Export on the phone: expo-print renders the document's HTML to a PDF, + * which is renamed to the website's file name and handed to the share sheet. + */ +export async function sharePdf(html: string, fileName: string, dialogTitle: string): Promise { + const { uri } = await Print.printToFileAsync({ html }); + const target = new File(Paths.cache, fileName); + if (target.exists) target.delete(); + await new File(uri).move(target); + await Sharing.shareAsync(target.uri, { mimeType: 'application/pdf', UTI: 'com.adobe.pdf', dialogTitle }); +} + +export async function copyText(text: string): Promise { + await Clipboard.setStringAsync(text); +} + +/** The share button: the document as plain text, through the iOS share sheet. */ +export async function shareText(title: string, text: string): Promise { + await Share.share({ title, message: text }); +} diff --git a/apps/mobile/src/lib/export.web.ts b/apps/mobile/src/lib/export.web.ts new file mode 100644 index 000000000..894085a3b --- /dev/null +++ b/apps/mobile/src/lib/export.web.ts @@ -0,0 +1,29 @@ +import * as Clipboard from 'expo-clipboard'; + +/** + * The web preview cannot make a PDF: expo-print's printToFileAsync does not + * exist there. It keeps the HTML it would print and the text it copied on + * window, so the screenshot script can show and check them. + */ +type ExportProbe = { __debatelabExport?: { html?: string; fileName?: string; copied?: string; shared?: string } }; + +function probe() { + const target = window as unknown as ExportProbe; + target.__debatelabExport ??= {}; + return target.__debatelabExport; +} + +export function sharePdf(html: string, fileName: string): Promise { + Object.assign(probe(), { html, fileName }); + return Promise.resolve(); +} + +export async function copyText(text: string): Promise { + probe().copied = text; + await Clipboard.setStringAsync(text).catch((): undefined => undefined); +} + +export function shareText(_title: string, text: string): Promise { + probe().shared = text; + return Promise.resolve(); +} diff --git a/apps/mobile/src/lib/ask-config.ts b/apps/mobile/src/lib/feature-config.ts similarity index 78% rename from apps/mobile/src/lib/ask-config.ts rename to apps/mobile/src/lib/feature-config.ts index 9395dd03a..ad3bbb2b5 100644 --- a/apps/mobile/src/lib/ask-config.ts +++ b/apps/mobile/src/lib/feature-config.ts @@ -1,4 +1,4 @@ -import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; import { canAccessProModels } from '@debatelab/core/ai/pro-access'; import { AUTO_FAST, @@ -14,19 +14,19 @@ import { import { supabase } from './supabase'; /** - * The Ask model and its settings, picked exactly as the website's settings + * A tool's model and its settings, picked exactly as the website's settings * store (src/lib/stores/settings-store.ts) picks them from * user_settings.feature_configs. The app only reads them: the model picker * and the migration that rewrites old configs stay on the website. */ -export interface AskConfig { +export interface FeatureConfig { model: string; temperature: number; reasoningEffort: ReasoningEffort; } -function defaultConfig(model: string = DEFAULT_MODEL): AskConfig { - const { temperature, reasoningEffort } = getDefaultFeatureConfigs(model).ask; +function defaultConfig(feature: FeatureTab, model: string = DEFAULT_MODEL): FeatureConfig { + const { temperature, reasoningEffort } = getDefaultFeatureConfigs(model)[feature]; return { model, temperature, reasoningEffort: reasoningEffort ?? 'low' }; } @@ -57,7 +57,7 @@ function normalizeModelId(value: unknown, fallback: string): string { return LEGACY_MODEL_SUCCESSORS[model] ?? DEFAULT_MODEL; } -function normalizeConfig(value: unknown, fallback: AskConfig, allowProModels: boolean): AskConfig { +function normalizeConfig(value: unknown, fallback: FeatureConfig, allowProModels: boolean): FeatureConfig { const candidate = asRecord(value); const model = normalizeModelId(candidate?.model, fallback.model); return { @@ -73,13 +73,13 @@ function normalizeConfig(value: unknown, fallback: AskConfig, allowProModels: bo }; } -export async function fetchAskConfig(userId: string): Promise { +export async function fetchFeatureConfig(userId: string, feature: FeatureTab): Promise { const { data, error } = await supabase .from('user_settings') .select('feature_configs, subscription_tier, grace_period_ends_at') .eq('user_id', userId) .maybeSingle(); - if (error || !data) return defaultConfig(); + if (error || !data) return defaultConfig(feature); // Null when it cannot be told, as on the website, which then keeps its defaults. const allowProModels = await canAccessProModels(data, async () => { const { data: rows, error: billingError } = await supabase @@ -88,7 +88,7 @@ export async function fetchAskConfig(userId: string): Promise { .eq('user_id', userId); return billingError ? null : rows ?? []; }); - if (allowProModels === null) return defaultConfig(); - const fallback = defaultConfig(allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL); - return normalizeConfig(asRecord(data.feature_configs)?.ask, fallback, allowProModels); + if (allowProModels === null) return defaultConfig(feature); + const fallback = defaultConfig(feature, allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL); + return normalizeConfig(asRecord(data.feature_configs)?.[feature], fallback, allowProModels); } diff --git a/apps/mobile/src/lib/motion-gate.ts b/apps/mobile/src/lib/motion-gate.ts new file mode 100644 index 000000000..a90b36981 --- /dev/null +++ b/apps/mobile/src/lib/motion-gate.ts @@ -0,0 +1,30 @@ +import { Alert } from 'react-native'; + +import type { Debate } from './debates'; + +export type MotionChoice = { kind: 'switch'; debateId: string } | { kind: 'create' } | { kind: 'one-off' } | null; + +export interface MotionGateText { + title: string; + message: string; + switchLabel: string; + createLabel: string; + onceLabel: string; + cancel: string; +} + +/** + * The website's DebateMotionGate as a native alert: a motion other than the + * Current Debate's asks whether to switch to a debate with that motion, + * create one, or use the motion once. Resolves null when cancelled. + */ +export function chooseMotionTarget(matches: Debate[], text: MotionGateText): Promise { + return new Promise((resolve) => { + Alert.alert(text.title, text.message, [ + ...matches.map((debate) => ({ text: text.switchLabel, onPress: () => resolve({ kind: 'switch' as const, debateId: debate.id }) })), + { text: text.createLabel, onPress: () => resolve({ kind: 'create' }) }, + { text: text.onceLabel, onPress: () => resolve({ kind: 'one-off' }) }, + { text: text.cancel, style: 'cancel', onPress: () => resolve(null) }, + ], { cancelable: true, onDismiss: () => resolve(null) }); + }); +} diff --git a/apps/mobile/src/lib/motion-gate.web.ts b/apps/mobile/src/lib/motion-gate.web.ts new file mode 100644 index 000000000..e47ff6dc5 --- /dev/null +++ b/apps/mobile/src/lib/motion-gate.web.ts @@ -0,0 +1,11 @@ +import type { Debate } from './debates'; +import type { MotionChoice, MotionGateText } from './motion-gate'; + +/** react-native-web's Alert does nothing, so the web preview asks with the browser's dialogs. */ +export function chooseMotionTarget(matches: Debate[], text: MotionGateText): Promise { + const ask = (label: string) => window.confirm(`${text.title}\n\n${text.message}\n\n${label}?`); + if (matches[0] && ask(text.switchLabel)) return Promise.resolve({ kind: 'switch', debateId: matches[0].id }); + if (ask(text.createLabel)) return Promise.resolve({ kind: 'create' }); + if (ask(text.onceLabel)) return Promise.resolve({ kind: 'one-off' }); + return Promise.resolve(null); +} diff --git a/apps/mobile/src/lib/motion.ts b/apps/mobile/src/lib/motion.ts new file mode 100644 index 000000000..861826203 --- /dev/null +++ b/apps/mobile/src/lib/motion.ts @@ -0,0 +1,40 @@ +import type { Debate } from './debates'; + +/** + * The website's motion rules (src/lib/debate/motion.ts and normalizeMotion in + * src/lib/stores/debate-store.ts): which debate a motion-bearing tool's work + * belongs to, and when a form's motion follows the Current Debate. + */ + +export function normalizeMotion(motion: string): string { + return motion.trim().replace(/\s+/g, ' ').toLocaleLowerCase(); +} + +export function sameMotion(a: string | undefined | null, b: string | undefined | null): boolean { + return Boolean(a && b) && normalizeMotion(a!) === normalizeMotion(b!); +} + +export type MotionDecision = + | { kind: 'attach'; debateId: string } + | { kind: 'one-off' } + /** A different (or first) motion: the student chooses Switch, Create, or Use once. */ + | { kind: 'ask'; matches: Debate[] }; + +/** Only an exact motion match attaches silently; anything else asks. */ +export function decideMotionTarget(motion: string, state: { debates: Debate[]; current: Debate | null; oneOff: boolean }): MotionDecision { + if (state.oneOff) return { kind: 'one-off' }; + const { current } = state; + if (current && sameMotion(current.motion, motion)) return { kind: 'attach', debateId: current.id }; + return { + kind: 'ask', + matches: state.debates.filter((debate) => debate.id !== current?.id && sameMotion(debate.motion, motion)), + }; +} + +/** Fill the motion from the Current Debate only when the field is empty or still holds another debate's motion. */ +export function shouldPrefillMotion(formMotion: string | undefined, debateMotion: string, knownMotions: string[]): boolean { + const current = normalizeMotion(formMotion ?? ''); + if (!current) return true; + if (current === normalizeMotion(debateMotion)) return false; + return knownMotions.some((motion) => normalizeMotion(motion) === current); +} diff --git a/apps/mobile/src/lib/open-results.ts b/apps/mobile/src/lib/open-results.ts new file mode 100644 index 000000000..a9a85321f --- /dev/null +++ b/apps/mobile/src/lib/open-results.ts @@ -0,0 +1,48 @@ +import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; + +import type { OutputFor, ResultKind } from './results'; +import { sessionSlot } from './session-memory'; + +/** + * Results produced in this session, so their document opens at once and still + * opens when nothing was saved: one-off work, or a save that failed. The + * researched sources live only here, as they do on the website's page. They + * belong to the signed-in account and are forgotten when it changes. + */ +export interface OpenResult { + kind: K; + /** The saved row, or null while the result is not saved anywhere. */ + savedId: string | null; + title: string; + side: string | null; + debateId: string | null; + createdAt: number; + input: Record; + output: OutputFor[K]; + sources: DiscoveredSource[]; +} + +const store = sessionSlot(() => new Map()); +let localId = 0; + +/** Keeps a result under its saved id, or a local key when it is not saved. Returns the key to open it with. */ +export function keepOpenResult(result: OpenResult): string { + const key = result.savedId ?? `local-${Date.now()}-${(localId += 1)}`; + store.get().set(key, result as OpenResult); + return key; +} + +export function openResult(key: string, kind: K): OpenResult | undefined { + const result = store.get().get(key); + return result?.kind === kind ? (result as OpenResult) : undefined; +} + +export function updateOpenResult(key: string, patch: Partial>) { + const results = store.get(); + const result = results.get(key); + if (result) results.set(key, { ...result, ...patch }); +} + +export function forgetOpenResult(key: string) { + store.get().delete(key); +} diff --git a/apps/mobile/src/lib/result-text.ts b/apps/mobile/src/lib/result-text.ts new file mode 100644 index 000000000..588e0cb84 --- /dev/null +++ b/apps/mobile/src/lib/result-text.ts @@ -0,0 +1,93 @@ +import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; + +type Translate = (key: string, options?: Record) => string; + +/** A case as plain text, line for line as the website's Clipboard button copies it (GenerateResults). */ +export function caseToText(result: GenerateOutput): string { + const lines: string[] = []; + lines.push(`THESIS: ${result.caseStructure?.thesis || ''}`); + lines.push(`FRAMEWORK: ${result.caseStructure?.framework || ''}`); + lines.push(''); + result.contentions?.forEach((c, i) => { + lines.push(`CONTENTION #${i + 1}: ${c.claim}`); + lines.push(` Reasoning: ${c.reasoning}`); + lines.push(` Evidence: ${c.evidence}`); + lines.push(` Impact: ${c.impact}`); + lines.push(''); + }); + if (result.counters?.length) { + lines.push('PREPARED COUNTERS:'); + result.counters.forEach((c) => { + lines.push(` vs "${c.targetArgument}": ${c.counterpoint}`); + }); + } + return lines.join('\n'); +} + +/** + * An attack analysis as plain text. The website has no clipboard button for + * attacks, so this follows its Word export's sections and labels. + */ +export function attackToText(result: AttackOutput, t: Translate): string { + const lines: string[] = [t('export.caseAttack'), '']; + lines.push(`${t('export.overallScore')}: ${result.overallAssessment.score}/100`); + if (result.overallAssessment.summary) lines.push(result.overallAssessment.summary); + if (result.overallAssessment.strengthAreas?.length) { + lines.push('', `${t('export.strengthAreas')}:`); + result.overallAssessment.strengthAreas.forEach((item) => lines.push(` - ${item}`)); + } + lines.push('', `${t('export.weaknesses').toUpperCase()}:`); + if (result.weaknesses.length === 0) lines.push(` ${t('export.noWeaknesses')}`); + result.weaknesses.forEach((w, i) => { + lines.push(`${i + 1}. [${w.severity?.toUpperCase()}] ${w.description}`); + lines.push(` ${t('export.whyWeak')}: ${w.whyWeak || ''}`); + lines.push(` ${t('export.howOpponentAttacks')}: ${w.howOpponentAttacks || ''}`); + lines.push(` ${t('export.improvedVersion')}: ${w.improvedVersion || ''}`); + }); + lines.push('', `${t('export.fallacies').toUpperCase()}:`); + if ((result.fallaciesDetected ?? []).length === 0) lines.push(` ${t('export.noFallacies')}`); + (result.fallaciesDetected ?? []).forEach((f) => { + lines.push(`${f.name} (${t('export.location')}: ${f.location})`); + lines.push(` ${f.explanation}`); + }); + lines.push('', `${t('export.crossExQuestions').toUpperCase()}:`); + if ((result.crossExQuestions ?? []).length === 0) lines.push(` ${t('export.noCrossEx')}`); + (result.crossExQuestions ?? []).forEach((q, i) => { + lines.push(`Q${i + 1}: ${q.question}`); + lines.push(` ${t('export.purpose')}: ${q.purpose}`); + lines.push(` ${t('export.expectedTrap')}: ${q.expectedTrap}`); + }); + if (result.weighingAnalysis) { + lines.push('', `${t('export.weighingAnalysis').toUpperCase()}:`); + lines.push(result.weighingAnalysis.currentWeighing || t('export.noWeighingAnalysis')); + (result.weighingAnalysis.missingComparisons ?? []).forEach((item) => lines.push(` - ${item}`)); + (result.weighingAnalysis.improvementSuggestions ?? []).forEach((item) => lines.push(` → ${item}`)); + } + return lines.join('\n'); +} + +/** The message the website's Discuss button sends to Ask for a case (GeneratePage). */ +export function discussCaseMessage(result: GenerateOutput): string { + return `I'd like to discuss the case we just generated. + + **Thesis:** ${result.caseStructure.thesis} + + **Arguments:** + ${result.contentions.map((c, i) => `${i + 1}. ${c.claim}`).join('\n')} + + Can we refine this?`; +} + +/** The message the website's Discuss button sends to Ask for an attack (AttackPage). */ +export function discussAttackMessage(result: AttackOutput): string { + return `I'd like to discuss the critique of my case. + + **Overall Assessment:** ${result.overallAssessment.summary} + **Score:** ${result.overallAssessment.score}/100 + + **Top Weaknesses:** + ${result.weaknesses.slice(0, 3).map((w) => `- ${w.description} (${w.severity})`).join('\n')} + + Can you help me improve this?`; +} diff --git a/apps/mobile/src/lib/results.test.ts b/apps/mobile/src/lib/results.test.ts new file mode 100644 index 000000000..ea0e54a89 --- /dev/null +++ b/apps/mobile/src/lib/results.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; + +const addSource = vi.hoisted(() => vi.fn()); + +vi.mock('./supabase', () => ({ supabase: {} })); +vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); +vi.mock('./sources', () => ({ addSource, applySourceAssessments: vi.fn(), fetchSourceIdsInFolder: vi.fn(), removeSource: vi.fn() })); + +import { autosaveSources } from './results'; + +describe('autosaveSources', () => { + it('writes no further source once Generate or Attack is cancelled during a write', async () => { + const controller = new AbortController(); + let finishFirst = () => {}; + addSource.mockImplementationOnce(() => new Promise((resolve) => (finishFirst = resolve))); + addSource.mockResolvedValue(undefined); + const sources = ['a', 'b', 'c'].map((name) => ({ title: name, url: `https://example.com/${name}` })); + + const saving = autosaveSources('user-1', sources, 'generate', 'debate-1', controller.signal); + controller.abort(); + finishFirst(); + + await expect(saving).resolves.toEqual({ saved: 1, failed: 0 }); + expect(addSource).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts new file mode 100644 index 000000000..9d496c52a --- /dev/null +++ b/apps/mobile/src/lib/results.ts @@ -0,0 +1,249 @@ +import { + AUTO_SAVED_FOLDER, + autoSavedSourceRow, + parseDiscoveredSources, + retainedDiscoveredSources, + type DiscoveredSource, + type ResultWithSources, +} from '@debatelab/core/ai/discovered-sources'; +import { expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; +import { AttackOutputSchema, type AttackInput, type AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import { GenerateOutputSchema, type GenerateInput, type GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import type { SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; +import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; + +import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; +import type { FeatureConfig } from './feature-config'; +import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; +import { supabase } from './supabase'; + +/** + * Generate and Attack, sent and saved the way the website does it: + * generateCase and attackCase in src/lib/ai/client.ts for the requests and + * their researched sources, and recordResult and saveCurrentCase in + * src/lib/stores/session-store.ts for the saved rows. + */ + +export type ResultKind = 'case' | 'attack'; + +const TABLES = { case: 'cases', attack: 'attacks' } as const satisfies Record; + +export interface OutputFor { + case: GenerateOutput; + attack: AttackOutput; +} + +export interface SavedResult { + id: string; + kind: K; + title: string; + side: string | null; + debateId: string | null; + createdAt: number; + input: Record; + output: OutputFor[K]; +} + +interface RequestOptions { + config: FeatureConfig; + language: string; + /** Absent for one-off work. */ + debateContext?: DebateContextRequest; + signal?: AbortSignal; +} + +function requestBody(feature: 'generate' | 'attack', input: Record, options: RequestOptions) { + return { + feature, + input: { ...input, language: options.language }, + provider: PROVIDER, + model: options.config.model, + temperature: options.config.temperature, + reasoningEffort: options.config.reasoningEffort, + // Servers from before Current Debate context read only this field. + sourceIds: options.debateContext?.sourceIds ?? [], + ...(options.debateContext ? { debateContext: options.debateContext } : {}), + }; +} + +/** A generated case, checked like the website checks it and stamped with the local time and motion. */ +export function generateCase(input: GenerateInput, options: RequestOptions): Promise & ResponseExtras> { + return postFunction('ai', requestBody('generate', input, options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const parsed = GenerateOutputSchema.safeParse(transformGenerateResponse(output as Record)); + if (!parsed.success) throw new AIError('failed', 'AI response did not match the expected generate schema.'); + const result = parsed.data; + if (result.contentions.length !== expectedContentionCount(input.length)) { + throw new AIError('failed', 'AI response did not contain the expected number of contentions.'); + } + result.metadata.generatedAt = new Date().toISOString(); + result.metadata.motion = input.motion; + return { result, discoveredSources: parseDiscoveredSources(data.discoveredSources), ...responseExtras(data) }; + }); +} + +export function attackCase(input: AttackInput, options: RequestOptions): Promise & ResponseExtras> { + return postFunction('ai', requestBody('attack', input, options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const parsed = AttackOutputSchema.safeParse(output); + if (!parsed.success) throw new AIError('failed', 'AI response did not match the expected attack schema.'); + return { result: parsed.data, discoveredSources: parseDiscoveredSources(data.discoveredSources), ...responseExtras(data) }; + }); +} + +/** + * Keeps the researched sources worth keeping in the debate's Auto-saved + * folder, one at a time, as the website does. One-off research is not kept. + * A source that fails to save is skipped; the count of failures is returned. + * Once the request is cancelled or replaced, no further source is written. + */ +export async function autosaveSources( + userId: string, + sources: DiscoveredSource[], + capturedVia: 'generate' | 'attack', + debateId: string | null, + signal?: AbortSignal, +): Promise<{ saved: number; failed: number }> { + if (!debateId) return { saved: 0, failed: 0 }; + let saved = 0; + let failed = 0; + for (const source of retainedDiscoveredSources(sources)) { + if (signal?.aborted) break; + try { + await addSource(userId, { ...autoSavedSourceRow(source, capturedVia), debateId }); + saved += 1; + } catch (cause) { + console.warn('Could not save a researched source:', cause instanceof Error ? cause.message : cause); + failed += 1; + } + } + return { saved, failed }; +} + +/** + * Saves the rankings of the Library sources a result used, into the debate + * the request was sent for. One-off work has no Library to rank. Returns + * false when the rankings could not be saved; the result itself still stands. + * Once the request is cancelled or replaced, no ranking is written. + */ +export async function keepSourceAssessments(userId: string, debateId: string | null, assessments: SourceAssessmentUpdate[], signal?: AbortSignal): Promise { + if (!debateId || assessments.length === 0) return true; + try { + await applySourceAssessments(userId, debateId, assessments, signal); + return true; + } catch (cause) { + console.warn('Could not save the source rankings:', cause instanceof Error ? cause.message : cause); + return false; + } +} + +/** Problems from making a result, told once when its document opens. */ +export type ArrivalNotice = 'recordFailed' | 'sourcesFailed' | 'assessmentsFailed' | 'shortened'; + +/** The arrival notices as one route parameter, or undefined when there is nothing to tell. */ +export function arrivalNotices(outcome: { recordFailed: boolean; sourcesFailed: number; assessed: boolean; truncated: boolean }): string | undefined { + const notices: ArrivalNotice[] = []; + if (outcome.recordFailed) notices.push('recordFailed'); + else if (outcome.sourcesFailed > 0) notices.push('sourcesFailed'); + if (!outcome.assessed) notices.push('assessmentsFailed'); + if (outcome.truncated) notices.push('shortened'); + return notices.length > 0 ? notices.join(',') : undefined; +} + +/** The website's rule for its undo: every source in the debate's Auto-saved folder. */ +export function fetchAutoSavedSourceIds(userId: string, debateId: string): Promise { + return fetchSourceIdsInFolder(userId, debateId, AUTO_SAVED_FOLDER); +} + +/** Removes the debate's auto-saved sources; a source moved to another debate since is left alone. */ +export async function removeSources(userId: string, debateId: string, ids: string[]): Promise<{ succeeded: number; failed: number }> { + const results = await Promise.allSettled(ids.map((id) => removeSource(userId, id, debateId))); + const succeeded = results.filter((result) => result.status === 'fulfilled').length; + return { succeeded, failed: results.length - succeeded }; +} + +/** + * Every read and write below also filters on the user. Row level security is + * the real boundary; the filter keeps a wrong session from mixing accounts. + */ + +/** Saves a result into its debate, as the website's recordResult. Returns the new row's id. */ +export async function recordResult( + userId: string, + kind: K, + record: { title: string; input: unknown; output: OutputFor[K]; debateId: string; side: string }, +): Promise { + const { data, error } = await supabase + .from(TABLES[kind]) + .insert({ + user_id: userId, + debate_id: record.debateId, + side: record.side, + title: record.title.slice(0, 300) || 'Untitled', + input: record.input ?? {}, + output: record.output, + }) + .select('id') + .single(); + if (error || !data) throw new Error(error?.message ?? `Could not save the ${kind}`); + return (data as { id: string }).id; +} + +/** Saves a case outside any debate, as the website's Save button does for unrecorded work. */ +export async function saveCase(userId: string, title: string, output: GenerateOutput): Promise { + const { data, error } = await supabase + .from('cases') + .insert({ user_id: userId, title, input: {}, output }) + .select('id') + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not save the case'); + return (data as { id: string }).id; +} + +interface ResultRow { + id: string; + title: string; + side: string | null; + debate_id: string | null; + created_at: string; + input: unknown; + output: unknown; +} + +export async function fetchResult(userId: string, kind: K, id: string): Promise | null> { + const { data, error } = await supabase + .from(TABLES[kind]) + .select('id,title,side,debate_id,created_at,input,output') + .eq('user_id', userId) + .eq('id', id) + .maybeSingle(); + if (error) throw new Error(error.message); + if (!data) return null; + const row = data as ResultRow; + return { + id: row.id, + kind, + title: row.title, + side: row.side, + debateId: row.debate_id, + createdAt: new Date(row.created_at).getTime(), + input: row.input && typeof row.input === 'object' && !Array.isArray(row.input) ? (row.input as Record) : {}, + output: row.output as OutputFor[K], + }; +} + +export async function deleteResult(userId: string, kind: ResultKind, id: string): Promise { + const { error } = await supabase.from(TABLES[kind]).delete().eq('user_id', userId).eq('id', id); + if (error) throw new Error(error.message); +} + +/** The guard the website's results pages use: an old or broken row shows a notice instead of a crash. */ +export function isCompleteCase(output: unknown): output is GenerateOutput { + const value = output as Partial | null; + return Boolean(value?.caseStructure && Array.isArray(value.contentions) && value.predictedOpponentCase && Array.isArray(value.counters)); +} + +export function isCompleteAttack(output: unknown): output is AttackOutput { + const value = output as Partial | null; + return Boolean(value?.overallAssessment && Array.isArray(value.weaknesses)); +} diff --git a/apps/mobile/src/lib/session-memory.ts b/apps/mobile/src/lib/session-memory.ts new file mode 100644 index 000000000..21bb3d813 --- /dev/null +++ b/apps/mobile/src/lib/session-memory.ts @@ -0,0 +1,30 @@ +/** + * What the app remembers for the session outside React: the last Generate and + * Attack forms and the results made in this session. It belongs to the + * signed-in account and is forgotten whenever the account changes, including + * signing out, so the next account never sees it. + */ + +const resets = new Set<() => void>(); +let owner: string | null = null; + +/** A value that starts from `initial()` again for every account. */ +export function sessionSlot(initial: () => T): { get: () => T; set: (value: T) => void } { + let value = initial(); + resets.add(() => { + value = initial(); + }); + return { + get: () => value, + set: (next) => { + value = next; + }, + }; +} + +/** Called by the auth provider before it adopts a session, so no screen renders with the previous account's memory. */ +export function setSessionOwner(userId: string | null) { + if (userId === owner) return; + owner = userId; + resets.forEach((reset) => reset()); +} diff --git a/apps/mobile/src/lib/sources.test.ts b/apps/mobile/src/lib/sources.test.ts new file mode 100644 index 000000000..c07b1ae2a --- /dev/null +++ b/apps/mobile/src/lib/sources.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +const supabase = vi.hoisted(() => ({ from: vi.fn() })); + +vi.mock('./supabase', () => ({ supabase })); + +import { applySourceAssessments } from './sources'; + +describe('applySourceAssessments', () => { + it('writes no ranking once Generate, Attack or Ask is cancelled while the sources are read', async () => { + const controller = new AbortController(); + let finishRead = (_rows: unknown) => {}; + const read = new Promise((resolve) => (finishRead = resolve)); + const chain = { eq: () => chain, then: read.then.bind(read) }; + const written = { eq: () => written, then: (done: (value: unknown) => unknown) => done({ error: null }) }; + const update = vi.fn(() => written); + supabase.from.mockReturnValue({ select: () => chain, update }); + const assessment = { key: 'https://example.com/a', relevance: 0.8, importance: 2 as const, strength: 2 as const, evaluatedAt: '2026-10-09T00:00:00Z', retained: true }; + + const applying = applySourceAssessments('user-1', 'debate-1', [assessment], controller.signal); + controller.abort(); + finishRead({ data: [{ id: 'source-a', origin: 'https://example.com/a', metadata: null }], error: null }); + + await expect(applying).resolves.toBeUndefined(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index f4027fb94..9a6283402 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -1,4 +1,6 @@ +import type { SourceSemanticMetadata } from '@debatelab/core/ai/discovered-sources'; import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/core/ai/schemas/source-import'; +import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; import { supabase } from './supabase'; @@ -82,14 +84,28 @@ export async function fetchSource(userId: string, id: string): Promise | null; + }, ): Promise { + const folder = input.folder?.trim() || DEFAULT_FOLDER; const folderResult = await supabase .from('source_folders') - .upsert({ user_id: userId, name: DEFAULT_FOLDER }, { onConflict: 'user_id,name' }); + .upsert({ user_id: userId, name: folder }, { onConflict: 'user_id,name' }); if (folderResult.error) throw new Error(folderResult.error.message); const { data, error } = await supabase .from('sources') @@ -100,9 +116,9 @@ export async function addSource( content: input.content, type: input.type, origin: input.origin || null, - folder: DEFAULT_FOLDER, - is_active: true, - metadata: null, + folder, + is_active: input.isActive ?? true, + metadata: input.metadata ?? null, }) .select(SOURCE_COLUMNS) .single(); @@ -111,10 +127,57 @@ export async function addSource( return fromRow(data as SourceRow); } -export async function removeSource(userId: string, id: string): Promise { - const { error } = await supabase.from('sources').delete().eq('user_id', userId).eq('id', id); +/** The sources in one debate's folder, as the website's undo for auto-saved research finds them. */ +export async function fetchSourceIdsInFolder(userId: string, debateId: string, folder: string): Promise { + const { data, error } = await supabase + .from('sources') + .select('id') + .eq('user_id', userId) + .eq('debate_id', debateId) + .eq('folder', folder); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string }[]).map((row) => row.id); +} + +/** Removes a source; with a debate, only while the source is still in that debate. */ +export async function removeSource(userId: string, id: string, debateId?: string): Promise { + const query = supabase.from('sources').delete().eq('user_id', userId).eq('id', id); + const { error } = await (debateId ? query.eq('debate_id', debateId) : query); + if (error) throw new Error(error.message); + notifySourcesChanged(); +} + +/** + * Saves the server's rankings onto the debate's Library sources a result + * used, as the website's applySourceAssessments does. The debate is the one + * the request was sent for, read when the student acted; a URL saved in + * several debates is ranked only in that one. Once the request is cancelled + * or replaced, nothing is written. + */ +export async function applySourceAssessments(userId: string, debateId: string, assessments: SourceAssessmentUpdate[], signal?: AbortSignal): Promise { + if (assessments.length === 0 || signal?.aborted) return; + const { data, error } = await supabase + .from('sources') + .select('id,origin,metadata') + .eq('user_id', userId) + .eq('debate_id', debateId); + if (signal?.aborted) return; if (error) throw new Error(error.message); + type Row = { id: string; origin: string | null; metadata: { url?: string; semantic?: SourceSemanticMetadata } | null }; + const candidates = (data ?? []) as Row[]; + const updates = new Map(); + for (const assessment of assessments.slice(0, 10)) { + const source = candidates.find((candidate) => isAssessedSource(candidate, assessment)); + if (!source) continue; + updates.set(source.id, { ...source.metadata, semantic: assessedSemantic(source.metadata?.semantic, assessment) }); + } + if (updates.size === 0) return; + const results = await Promise.all( + Array.from(updates, ([id, metadata]) => supabase.from('sources').update({ metadata }).eq('user_id', userId).eq('debate_id', debateId).eq('id', id)), + ); notifySourcesChanged(); + const failed = results.find((result) => result.error); + if (failed?.error) throw new Error(failed.error.message); } /** The website rejects a URL that is already in the debate's Library. */ diff --git a/apps/mobile/src/lib/use-result-document.ts b/apps/mobile/src/lib/use-result-document.ts new file mode 100644 index 000000000..1d48539c5 --- /dev/null +++ b/apps/mobile/src/lib/use-result-document.ts @@ -0,0 +1,229 @@ +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; + +import { useAuth } from './auth'; +import { confirmDestructive, promptText } from './confirm'; +import { useDebates } from './debate-context'; +import { forgetOpenResult, keepOpenResult, openResult, updateOpenResult, type OpenResult } from './open-results'; +import { deleteResult, fetchAutoSavedSourceIds, fetchResult, removeSources, saveCase, type ArrivalNotice, type ResultKind } from './results'; +import { fetchSources, subscribeSourcesChanged, type Source } from './sources'; + +export type DocumentState = + | { status: 'loading' } + | { status: 'missing' } + | { status: 'failed' } + | { status: 'ready'; key: string; result: OpenResult }; + +const NOTICE_MS = 3500; + +/** The arrival notices a fresh result carries in its route, as one line; empty when there are none. */ +export function arrivalNoticeText(t: TFunction, notice: string | undefined, failed: string | undefined): string { + const text: Record string> = { + recordFailed: () => t('debate.recordFailed'), + sourcesFailed: () => t('mobile.results.sourcesFailed', { count: Number(failed) || 1 }), + assessmentsFailed: () => `${t('aiClient.sourceSaveFailed')}. ${t('aiClient.sourceAssessmentSaveFailed')}`, + shortened: () => `${t('aiClient.inputShortened')}. ${t('aiClient.inputShortenedDesc')}`, + }; + return (notice ?? '') + .split(',') + .flatMap((code) => (code in text ? [text[code as ArrivalNotice]()] : [])) + .join(' '); +} + +/** + * A result's document: the result itself (from this session or the saved + * row), the debate's Library for its citation chips, the Current Debate's + * auto-saved sources for the undo, and the menu's actions. Every action reads + * the account and the debate when the student acts, and nothing lands after + * the screen is left or the account changes. + */ +export function useResultDocument(kind: K, id: string) { + const { t } = useTranslation(); + const router = useRouter(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { current } = useDebates(); + const [state, setState] = useState>(() => { + const cached = openResult(id, kind); + return cached ? { status: 'ready', key: id, result: cached } : { status: 'loading' }; + }); + const [library, setLibrary] = useState([]); + // The ids belong to the debate they were read for; another debate never sees or deletes them. + const [autoSavedFor, setAutoSavedFor] = useState<{ debateId: string; ids: string[] } | null>(null); + const [notice, setNoticeState] = useState(''); + const [busy, setBusy] = useState(false); + const live = useRef(true); + const owner = useRef(userId); + owner.current = userId; + const noticeTimer = useRef | null>(null); + + useEffect(() => () => { + live.current = false; + if (noticeTimer.current) clearTimeout(noticeTimer.current); + }, []); + + const setNotice = useCallback((text: string) => { + if (!live.current) return; + setNoticeState(text); + if (noticeTimer.current) clearTimeout(noticeTimer.current); + noticeTimer.current = setTimeout(() => { + if (live.current) setNoticeState(''); + }, NOTICE_MS); + }, []); + + // Load the saved row unless this session already holds the result. + useEffect(() => { + if (openResult(id, kind)) return; + if (!userId || id.startsWith('local-')) { + setState({ status: id.startsWith('local-') ? 'missing' : 'loading' }); + return; + } + let active = true; + fetchResult(userId, kind, id) + .then((row) => { + if (!active) return; + if (!row) { + setState({ status: 'missing' }); + return; + } + const result: OpenResult = { ...row, savedId: row.id, sources: [] }; + keepOpenResult(result); + setState({ status: 'ready', key: row.id, result }); + }) + .catch((cause: unknown) => { + console.warn('Could not load the result:', cause instanceof Error ? cause.message : cause); + if (active) setState({ status: 'failed' }); + }); + return () => { + active = false; + }; + }, [userId, kind, id]); + + const result = state.status === 'ready' ? state.result : null; + const resultDebateId = result?.debateId ?? null; + + // The result's own debate's Library, for its citation chips. + useEffect(() => { + if (!userId || !resultDebateId) { + setLibrary([]); + return; + } + let active = true; + const load = () => + fetchSources(userId, resultDebateId) + .then((next) => { + if (active) setLibrary(next); + }) + .catch((cause: unknown) => console.warn('Could not load sources for citations:', cause instanceof Error ? cause.message : cause)); + void load(); + const unsubscribe = subscribeSourcesChanged(() => void load()); + return () => { + active = false; + unsubscribe(); + }; + }, [userId, resultDebateId]); + + // The website's undo covers the Current Debate's Auto-saved folder, also in one-off mode. + const currentId = current?.id ?? null; + const currentRef = useRef(currentId); + currentRef.current = currentId; + const autoSaved = autoSavedFor && autoSavedFor.debateId === currentId ? autoSavedFor.ids : []; + const autoSavedRequest = useRef(0); + const loadAutoSaved = useCallback(() => { + const request = ++autoSavedRequest.current; + if (!userId || !currentId) { + setAutoSavedFor(null); + return; + } + const debateId = currentId; + fetchAutoSavedSourceIds(userId, debateId) + .then((ids) => { + if (live.current && autoSavedRequest.current === request) setAutoSavedFor({ debateId, ids }); + }) + .catch((cause: unknown) => console.warn('Could not count auto-saved sources:', cause instanceof Error ? cause.message : cause)); + }, [userId, currentId]); + useFocusEffect(loadAutoSaved); + + const remove = useCallback(async () => { + if (!result?.savedId || !userId) return; + const forUser = userId; + const savedId = result.savedId; + const confirmed = await confirmDestructive({ + title: kind === 'case' ? t('historyPage.deleteCaseTitle') : t('mobile.results.deleteAttackTitle'), + message: kind === 'case' ? t('historyPage.deleteCaseDesc') : t('mobile.results.deleteAttackDesc'), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed || !live.current || owner.current !== forUser) return; + setBusy(true); + try { + await deleteResult(forUser, kind, savedId); + forgetOpenResult(savedId); + if (state.status === 'ready') forgetOpenResult(state.key); + // A document opened from a link has nothing behind it; go home instead. + if (live.current) { + if (router.canGoBack()) router.back(); + else router.replace('/'); + } + } catch (cause) { + console.warn('Could not delete the result:', cause instanceof Error ? cause.message : cause); + setNotice(t('mobile.results.deleteFailed')); + } finally { + if (live.current) setBusy(false); + } + }, [kind, result, router, setNotice, state, t, userId]); + + /** Saves an unsaved case outside any debate, as the website's Save button. */ + const save = useCallback(async () => { + if (state.status !== 'ready' || state.result.savedId || state.result.kind !== 'case' || !userId) return; + const forUser = userId; + const { key, result: open } = state; + const title = await promptText({ + title: t('generate.saveCase'), + message: t('generate.savePrompt'), + defaultValue: open.title, + confirm: t('common.save'), + cancel: t('common.cancel'), + }); + if (!title?.trim() || !live.current || owner.current !== forUser) return; + setBusy(true); + try { + const savedId = await saveCase(forUser, title.trim(), open.output as OpenResult<'case'>['output']); + updateOpenResult(key, { savedId, title: title.trim() }); + if (!live.current) return; + setState({ status: 'ready', key, result: { ...open, savedId, title: title.trim() } }); + setNotice(t('generate.saved')); + } catch (cause) { + console.warn('Could not save the case:', cause instanceof Error ? cause.message : cause); + setNotice(t('generate.saveFailedDesc')); + } finally { + if (live.current) setBusy(false); + } + }, [setNotice, state, t, userId]); + + const undoAutoSaved = useCallback(async () => { + if (!userId || !autoSavedFor || autoSavedFor.ids.length === 0) return; + // The ids read for one debate never delete anything after a switch to another. + if (autoSavedFor.debateId !== currentRef.current) { + loadAutoSaved(); + return; + } + const forUser = userId; + const { debateId, ids } = autoSavedFor; + setBusy(true); + try { + const { succeeded, failed } = await removeSources(forUser, debateId, ids); + if (!live.current || owner.current !== forUser) return; + if (failed === 0) setNotice(`${t('autoSavedSources.removedTitle')}. ${t('autoSavedSources.removedDesc', { count: succeeded })}`); + else if (succeeded === 0) setNotice(`${t('autoSavedSources.removeFailed')} ${t('autoSavedSources.removeFailedDesc', { succeeded, failed })}`); + else setNotice(`${t('autoSavedSources.removedPartialTitle')}. ${t('autoSavedSources.removedPartialDesc', { succeeded, failed })}`); + } finally { + if (live.current) setBusy(false); + loadAutoSaved(); + } + }, [autoSavedFor, loadAutoSaved, setNotice, t, userId]); + + return { state, library, autoSaved, notice, setNotice, busy, remove, save, undoAutoSaved }; +} diff --git a/apps/mobile/src/theme/colors.ts b/apps/mobile/src/theme/colors.ts index 883579aaa..4debe1277 100644 --- a/apps/mobile/src/theme/colors.ts +++ b/apps/mobile/src/theme/colors.ts @@ -25,6 +25,9 @@ export const palette = { tabBar: 'rgba(248,250,252,0.84)', destructive: '#dc2626', success: '#16a34a', + warning: '#d97706', + tileBorder: '#bae6fd', + tileText: '#0c4a6e', }, dark: { background: '#0f172a', @@ -47,6 +50,9 @@ export const palette = { tabBar: 'rgba(15,23,42,0.84)', destructive: '#f87171', success: '#4ade80', + warning: '#fbbf24', + tileBorder: '#1e4a6b', + tileText: '#e0f2fe', }, } as const; diff --git a/package-lock.json b/package-lock.json index 3f22ef75d..1cb4e067a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,14 +125,18 @@ "aes-js": "^3.1.2", "date-fns": "^3.6.0", "expo": "~57.0.27", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", + "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", + "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", + "expo-sharing": "~57.0.22", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", @@ -549,6 +553,17 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-clipboard": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz", + "integrity": "sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-constants": { "version": "57.0.21", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.21.tgz", @@ -684,6 +699,16 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-print": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-57.0.2.tgz", + "integrity": "sha512-jEbUqtH59xT42dCrmVVrg9Ct1ofD5GAHdQmUiu0EkHdzvqop7G6dt7rCXpq0iUYbu8ow6HH8yLRPbd3VMdTUug==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-router": { "version": "57.0.25", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz", @@ -765,6 +790,22 @@ "expo": "*" } }, + "apps/mobile/node_modules/expo-sharing": { + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.22.tgz", + "integrity": "sha512-IWmiz0Dnjd7ifsZ2Gg5XODxb6DQoJ4/H6FofW9bTL9gDjfX2+va8SV1BYVkFvJ8vKQoKR4HgkMCKDCZlrrbKkg==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^57.0.9", + "@expo/config-types": "^57.0.2", + "@expo/plist": "^0.8.1" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-splash-screen": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.9.tgz", diff --git a/packages/core/src/ai/discovered-sources.ts b/packages/core/src/ai/discovered-sources.ts new file mode 100644 index 000000000..6fe981b38 --- /dev/null +++ b/packages/core/src/ai/discovered-sources.ts @@ -0,0 +1,131 @@ +import { validateUrl } from '../validation/url'; + +export interface SourceSemanticMetadata { + relevance: number; + importance: 0 | 1 | 2 | 3; + strength: 0 | 1 | 2 | 3; + evaluatedAt: string; + useCount: number; + caseUseCount: number; + lastUsedAt?: string; + lastUsedInCase?: boolean; +} + +/** A web source discovered during research, with auditable ranking metadata from the edge function. */ +export interface DiscoveredSource { + title: string; + url: string; + content?: string; + sourceType?: string; + qualityScore?: number; + qualityLabel?: 'strong' | 'moderate' | 'weak'; + query?: string; + capturedVia?: string; + semantic?: SourceSemanticMetadata; +} + +/** Feature result plus the researched sources that informed it. */ +export interface ResultWithSources { + result: T; + discoveredSources: DiscoveredSource[]; +} + +export function parseSourceSemanticMetadata(value: unknown): SourceSemanticMetadata | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const semantic = value as Record; + const { relevance, importance, strength, evaluatedAt, useCount, caseUseCount } = semantic; + if (typeof relevance !== 'number' || !Number.isFinite(relevance) || relevance < 0 || relevance > 1 || + typeof importance !== 'number' || !Number.isInteger(importance) || importance < 0 || importance > 3 || + typeof strength !== 'number' || !Number.isInteger(strength) || strength < 0 || strength > 3 || + typeof evaluatedAt !== 'string' || !Number.isFinite(Date.parse(evaluatedAt)) || + typeof useCount !== 'number' || !Number.isInteger(useCount) || useCount < 0 || + typeof caseUseCount !== 'number' || !Number.isInteger(caseUseCount) || caseUseCount < 0 || + (semantic.lastUsedAt !== undefined && typeof semantic.lastUsedAt !== 'string') || + (semantic.lastUsedInCase !== undefined && typeof semantic.lastUsedInCase !== 'boolean')) return undefined; + return { + relevance, + importance: importance as 0 | 1 | 2 | 3, + strength: strength as 0 | 1 | 2 | 3, + evaluatedAt, + useCount, + caseUseCount, + ...(typeof semantic.lastUsedAt === 'string' ? { lastUsedAt: semantic.lastUsedAt } : {}), + ...(typeof semantic.lastUsedInCase === 'boolean' ? { lastUsedInCase: semantic.lastUsedInCase } : {}), + }; +} + +/** + * Parses the discovered-sources payload at the trust boundary: only + * well-formed entries with a title and URL survive; anything else is dropped. + */ +export function parseDiscoveredSources(value: unknown): DiscoveredSource[] { + if (!Array.isArray(value)) return []; + + return value.flatMap((item): DiscoveredSource[] => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + + const candidate: Record = {}; + for (const [key, fieldValue] of Object.entries(item)) candidate[key] = fieldValue; + + const title = typeof candidate.title === 'string' ? candidate.title.trim() : ''; + const url = typeof candidate.url === 'string' ? candidate.url.trim() : ''; + const normalizedUrl = validateUrl(url); + if (!title || !normalizedUrl) return []; + + const source: DiscoveredSource = { title, url: normalizedUrl }; + if (typeof candidate.content === 'string') source.content = candidate.content; + + if (typeof candidate.sourceType === 'string' && candidate.sourceType.trim()) { + source.sourceType = candidate.sourceType.trim(); + } + + if (typeof candidate.qualityScore === 'number' && Number.isFinite(candidate.qualityScore) && + candidate.qualityScore >= 0 && candidate.qualityScore <= 100) { + source.qualityScore = candidate.qualityScore; + } + + if (candidate.qualityLabel === 'strong' || candidate.qualityLabel === 'moderate' || candidate.qualityLabel === 'weak') { + source.qualityLabel = candidate.qualityLabel; + } + + if (typeof candidate.query === 'string' && candidate.query.trim()) { + source.query = candidate.query.trim(); + } + + const semantic = parseSourceSemanticMetadata(candidate.semantic); + if (semantic) source.semantic = semantic; + + return [source]; + }); +} + +/** The Library folder researched sources are saved to. */ +export const AUTO_SAVED_FOLDER = 'Auto-saved'; + +/** The researched sources worth keeping in the Library: weak or off-topic ones are dropped unless the case used them. */ +export function retainedDiscoveredSources(sources: DiscoveredSource[]): DiscoveredSource[] { + return sources.filter((source) => !source.semantic || + (source.qualityLabel !== 'weak' && source.semantic.relevance >= 0.45) || + source.semantic.lastUsedInCase === true); +} + +/** The Library row an auto-saved source becomes, apart from its user and debate. */ +export function autoSavedSourceRow(source: DiscoveredSource, capturedVia: string) { + return { + title: source.title, + content: source.content ?? '', + type: 'url' as const, + origin: source.url, + folder: AUTO_SAVED_FOLDER, + isActive: source.qualityLabel === 'strong' || source.qualityLabel === 'moderate', + metadata: { + url: source.url, + ...(source.sourceType !== undefined ? { sourceType: source.sourceType } : {}), + ...(source.qualityScore !== undefined ? { qualityScore: source.qualityScore } : {}), + ...(source.qualityLabel !== undefined ? { qualityLabel: source.qualityLabel } : {}), + ...(source.query !== undefined ? { query: source.query } : {}), + ...(source.semantic !== undefined ? { semantic: source.semantic } : {}), + capturedVia, + }, + }; +} diff --git a/packages/core/src/ai/generate-output.ts b/packages/core/src/ai/generate-output.ts new file mode 100644 index 000000000..aa45be2fd --- /dev/null +++ b/packages/core/src/ai/generate-output.ts @@ -0,0 +1,128 @@ +import type { GenerateInput, GenerateOutput } from './schemas/generate'; + +function cleanGeneratedString(value: string) { + return value + .replace(/[—–]/g, ', ') + .replace(/\b(Magnitude|Probability|Timeframe):\s*/gi, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +export function cleanGeneratedOutput(value: T): T { + if (typeof value === 'string') return cleanGeneratedString(value) as T; + if (Array.isArray(value)) return value.map(cleanGeneratedOutput) as T; + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cleanGeneratedOutput(child)]) + ) as T; + } + return value; +} + +/** + * Transforms AI generate response from various formats to the expected GenerateOutput schema. + * This handles cases where the AI returns a different structure than requested. + */ +export function transformGenerateResponse(response: Record): GenerateOutput { + // If already in correct format, return as-is (with exordio preserved) + if (response.caseStructure && response.contentions && response.predictedOpponentCase && response.counters && response.metadata) { + // Ensure exordio is properly structured if present + const result = response as unknown as GenerateOutput; + if (response.exordio && typeof response.exordio === 'object') { + const ex = response.exordio as Record; + result.exordio = { + hook: String(ex.hook || ''), + transition: String(ex.transition || ''), + }; + } + return cleanGeneratedOutput(result); + } + + // Transform from wrong format to correct format + const wrongFormatContentions = response.contentions as Array<{ + title?: string; + claim?: string; + points?: string[]; + evidence?: string[] | string; + analysis?: string; + reasoning?: string; + impact?: string; + }> || []; + + const transformedContentions = wrongFormatContentions.map((c, i) => ({ + id: String(i + 1), + claim: c.claim || c.title || 'Argument', + reasoning: c.reasoning || c.analysis || (Array.isArray(c.points) ? c.points.join(' ') : ''), + evidence: Array.isArray(c.evidence) ? c.evidence.join(' ') : (c.evidence || ''), + needsSource: false, + sourceQuery: '', + impact: c.impact || c.analysis || '', + })); + + // Transform rebuttals to counters + const rebuttals = (response.rebuttals as Array<{ attack?: string; response?: string }>) || []; + const counters = rebuttals.map(r => ({ + targetArgument: r.attack || '', + counterpoint: r.response || '', + rebuttal: r.response || '', + })); + + // Transform potentialWeaknesses to predictedOpponentCase + const weaknesses = (response.potentialWeaknesses as string[]) || []; + + const result: GenerateOutput = { + caseStructure: { + thesis: (response.thesis as string) || (response.title as string) || '', + framework: 'General framework', + keyDefinitions: [], + burdenOfProof: 'We must prove that ' + ((response.thesis as string) || '').slice(0, 50), + }, + contentions: transformedContentions.length > 0 ? transformedContentions : [{ + id: '1', + claim: 'Main argument', + reasoning: 'Reasoning here', + evidence: 'Evidence here', + needsSource: false, + sourceQuery: '', + impact: 'Impact here', + }], + predictedOpponentCase: { + likelyArguments: weaknesses.slice(0, 3), + strategicWeaknesses: weaknesses, + keyClashes: ['Main clash point'], + }, + counters: counters.length > 0 ? counters : [{ + targetArgument: 'Opponent argument', + counterpoint: 'Our counter', + rebuttal: 'Rebuttal text', + }], + metadata: { + side: (response.side as 'pro' | 'con') || 'pro', + format: 'custom', + generatedAt: new Date().toISOString(), + }, + }; + + // Preserve exordio if present in the original response + if (response.exordio && typeof response.exordio === 'object') { + const ex = response.exordio as Record; + result.exordio = { + hook: String(ex.hook || ''), + transition: String(ex.transition || ''), + }; + } + + return cleanGeneratedOutput(result); +} + +export function expectedContentionCount(length: GenerateInput['length']): number { + switch (length) { + case 'standard': + return 3; + case 'comprehensive': + return 4; + case 'brief': + default: + return 2; + } +} diff --git a/packages/core/src/ai/source-assessments.ts b/packages/core/src/ai/source-assessments.ts new file mode 100644 index 000000000..3bc78e964 --- /dev/null +++ b/packages/core/src/ai/source-assessments.ts @@ -0,0 +1,60 @@ +import type { SourceSemanticMetadata } from './discovered-sources'; + +/** The ai function's ranking of a Library source a result used, from `data.sourceAssessments`. */ +export interface SourceAssessmentUpdate { + key: string; + id?: string; + relevance: number; + importance: 0 | 1 | 2 | 3; + strength: 0 | 1 | 2 | 3; + evaluatedAt: string; + retained: boolean; + usedInCase?: boolean; +} + +/** At most ten well-formed assessments; anything malformed is dropped. */ +export function parseSourceAssessments(value: unknown): SourceAssessmentUpdate[] { + if (!Array.isArray(value)) return []; + return value.slice(0, 10).flatMap((item): SourceAssessmentUpdate[] => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const candidate = item as Record; + const key = typeof candidate.key === 'string' ? candidate.key.trim() : ''; + const { relevance, importance, strength, evaluatedAt } = candidate; + if (!key || typeof relevance !== 'number' || !Number.isFinite(relevance) || relevance < 0 || relevance > 1 || + typeof importance !== 'number' || !Number.isInteger(importance) || importance < 0 || importance > 3 || + typeof strength !== 'number' || !Number.isInteger(strength) || strength < 0 || strength > 3 || + typeof evaluatedAt !== 'string' || !Number.isFinite(Date.parse(evaluatedAt)) || typeof candidate.retained !== 'boolean') return []; + return [{ + key, + ...(typeof candidate.id === 'string' && candidate.id.trim() ? { id: candidate.id.trim() } : {}), + relevance, + importance: importance as 0 | 1 | 2 | 3, + strength: strength as 0 | 1 | 2 | 3, + evaluatedAt, + retained: candidate.retained, + ...(typeof candidate.usedInCase === 'boolean' ? { usedInCase: candidate.usedInCase } : {}), + }]; + }); +} + +/** Whether an assessment is about this source: by id, or by the key the server gave it. */ +export function isAssessedSource(source: { id: string; origin?: string | null; metadata?: { url?: string } | null }, assessment: SourceAssessmentUpdate): boolean { + return source.id === assessment.id || source.id === assessment.key || source.origin === assessment.key || source.metadata?.url === assessment.key; +} + +/** The source's semantic metadata after one more use, ranked by this assessment. */ +export function assessedSemantic(prior: SourceSemanticMetadata | undefined, assessment: SourceAssessmentUpdate): SourceSemanticMetadata { + const usedInCase = assessment.usedInCase; + return { + relevance: assessment.relevance, + importance: assessment.importance, + strength: assessment.strength, + evaluatedAt: assessment.evaluatedAt, + useCount: (prior?.useCount ?? 0) + 1, + caseUseCount: (prior?.caseUseCount ?? 0) + (usedInCase === true ? 1 : 0), + lastUsedAt: assessment.evaluatedAt, + ...(usedInCase !== undefined + ? { lastUsedInCase: usedInCase } + : prior?.lastUsedInCase !== undefined ? { lastUsedInCase: prior.lastUsedInCase } : {}), + }; +} diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index e75b969ad..be681c7d3 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -19,15 +19,18 @@ import { activeDebateId, useDebateStore } from '../stores/debate-store'; import { parseDebateContextUsage, useDebateContextStore } from '../stores/debate-context-store'; import { currentDebateContextPlan } from '../debate/context-state'; import { requestSide, type DebateContextRequest } from '../../../supabase/functions/_shared/debate-context'; +import { cleanGeneratedOutput, expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; +import { parseSourceAssessments } from '@debatelab/core/ai/source-assessments'; import { - type SourceAssessmentUpdate, - type SourceProvenance, - type SourceSemanticMetadata, -} from '../stores/session-store'; + autoSavedSourceRow, + parseDiscoveredSources, + retainedDiscoveredSources, + type DiscoveredSource, + type ResultWithSources, +} from '@debatelab/core/ai/discovered-sources'; import { getActiveAccessToken, getActiveUserId, invokeAuthenticatedFunction, supabase } from '@/lib/supabase/client'; import { toast } from '@/hooks/use-toast'; import { captureException, captureProductEvent } from '@/lib/observability'; -import { validateUrl } from '@/lib/validation/url'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; import i18n from '@/i18n'; import { @@ -44,6 +47,8 @@ import { type CrossfireTurnSignal, } from '@/lib/crossfire/schemas'; import type { WorkspaceToolContext } from '@/lib/workspace/tool-context'; + +export type { DiscoveredSource, ResultWithSources }; import { parseVoicePayload, type VoicePayload } from '@/lib/voice/payload'; // Request timeout in milliseconds (200 seconds) @@ -156,112 +161,6 @@ function waitForAbortable(operation: () => PromiseLike, signal: AbortSigna }); } -/** A web source discovered during research, with auditable ranking metadata from the edge function. */ -export interface DiscoveredSource extends Omit { - title: string; - url: string; - content?: string; -} - -/** Feature result plus the researched sources that informed it. */ -export interface ResultWithSources { - result: T; - discoveredSources: DiscoveredSource[]; -} - -function parseSourceSemanticMetadata(value: unknown): SourceSemanticMetadata | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const semantic = value as Record; - const { relevance, importance, strength, evaluatedAt, useCount, caseUseCount } = semantic; - if (typeof relevance !== 'number' || !Number.isFinite(relevance) || relevance < 0 || relevance > 1 || - typeof importance !== 'number' || !Number.isInteger(importance) || importance < 0 || importance > 3 || - typeof strength !== 'number' || !Number.isInteger(strength) || strength < 0 || strength > 3 || - typeof evaluatedAt !== 'string' || !Number.isFinite(Date.parse(evaluatedAt)) || - typeof useCount !== 'number' || !Number.isInteger(useCount) || useCount < 0 || - typeof caseUseCount !== 'number' || !Number.isInteger(caseUseCount) || caseUseCount < 0 || - (semantic.lastUsedAt !== undefined && typeof semantic.lastUsedAt !== 'string') || - (semantic.lastUsedInCase !== undefined && typeof semantic.lastUsedInCase !== 'boolean')) return undefined; - return { - relevance, - importance: importance as 0 | 1 | 2 | 3, - strength: strength as 0 | 1 | 2 | 3, - evaluatedAt, - useCount, - caseUseCount, - ...(typeof semantic.lastUsedAt === 'string' ? { lastUsedAt: semantic.lastUsedAt } : {}), - ...(typeof semantic.lastUsedInCase === 'boolean' ? { lastUsedInCase: semantic.lastUsedInCase } : {}), - }; -} - -/** - * Parses the discovered-sources payload at the trust boundary: only - * well-formed entries with a title and URL survive; anything else is dropped. - */ -function parseDiscoveredSources(value: unknown): DiscoveredSource[] { - if (!Array.isArray(value)) return []; - - return value.flatMap((item): DiscoveredSource[] => { - if (!item || typeof item !== 'object' || Array.isArray(item)) return []; - - const candidate: Record = {}; - for (const [key, fieldValue] of Object.entries(item)) candidate[key] = fieldValue; - - const title = typeof candidate.title === 'string' ? candidate.title.trim() : ''; - const url = typeof candidate.url === 'string' ? candidate.url.trim() : ''; - const normalizedUrl = validateUrl(url); - if (!title || !normalizedUrl) return []; - - const source: DiscoveredSource = { title, url: normalizedUrl }; - if (typeof candidate.content === 'string') source.content = candidate.content; - - if (typeof candidate.sourceType === 'string' && candidate.sourceType.trim()) { - source.sourceType = candidate.sourceType.trim(); - } - - if (typeof candidate.qualityScore === 'number' && Number.isFinite(candidate.qualityScore) && - candidate.qualityScore >= 0 && candidate.qualityScore <= 100) { - source.qualityScore = candidate.qualityScore; - } - - if (candidate.qualityLabel === 'strong' || candidate.qualityLabel === 'moderate' || candidate.qualityLabel === 'weak') { - source.qualityLabel = candidate.qualityLabel; - } - - if (typeof candidate.query === 'string' && candidate.query.trim()) { - source.query = candidate.query.trim(); - } - - const semantic = parseSourceSemanticMetadata(candidate.semantic); - if (semantic) source.semantic = semantic; - - return [source]; - }); -} - -function parseSourceAssessments(value: unknown): SourceAssessmentUpdate[] { - if (!Array.isArray(value)) return []; - return value.slice(0, 10).flatMap((item): SourceAssessmentUpdate[] => { - if (!item || typeof item !== 'object' || Array.isArray(item)) return []; - const candidate = item as Record; - const key = typeof candidate.key === 'string' ? candidate.key.trim() : ''; - const { relevance, importance, strength, evaluatedAt } = candidate; - if (!key || typeof relevance !== 'number' || !Number.isFinite(relevance) || relevance < 0 || relevance > 1 || - typeof importance !== 'number' || !Number.isInteger(importance) || importance < 0 || importance > 3 || - typeof strength !== 'number' || !Number.isInteger(strength) || strength < 0 || strength > 3 || - typeof evaluatedAt !== 'string' || !Number.isFinite(Date.parse(evaluatedAt)) || typeof candidate.retained !== 'boolean') return []; - return [{ - key, - ...(typeof candidate.id === 'string' && candidate.id.trim() ? { id: candidate.id.trim() } : {}), - relevance, - importance: importance as 0 | 1 | 2 | 3, - strength: strength as 0 | 1 | 2 | 3, - evaluatedAt, - retained: candidate.retained, - ...(typeof candidate.usedInCase === 'boolean' ? { usedInCase: candidate.usedInCase } : {}), - }]; - }); -} - // Enable debug logging in development const DEBUG = import.meta.env.DEV; @@ -995,121 +894,6 @@ function isProviderErrorCode(code?: string): boolean { return !!code && /quota|billing|api[_-]?key|provider|insufficient|overloaded|rate/i.test(code); } -function cleanGeneratedString(value: string) { - return value - .replace(/[—–]/g, ', ') - .replace(/\b(Magnitude|Probability|Timeframe):\s*/gi, '') - .replace(/\s{2,}/g, ' ') - .trim(); -} - -function cleanGeneratedOutput(value: T): T { - if (typeof value === 'string') return cleanGeneratedString(value) as T; - if (Array.isArray(value)) return value.map(cleanGeneratedOutput) as T; - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, cleanGeneratedOutput(child)]) - ) as T; - } - return value; -} - -/** - * Transforms AI generate response from various formats to the expected GenerateOutput schema. - * This handles cases where the AI returns a different structure than requested. - */ -function transformGenerateResponse(response: Record): GenerateOutput { - // If already in correct format, return as-is (with exordio preserved) - if (response.caseStructure && response.contentions && response.predictedOpponentCase && response.counters && response.metadata) { - // Ensure exordio is properly structured if present - const result = response as unknown as GenerateOutput; - if (response.exordio && typeof response.exordio === 'object') { - const ex = response.exordio as Record; - result.exordio = { - hook: String(ex.hook || ''), - transition: String(ex.transition || ''), - }; - } - return cleanGeneratedOutput(result); - } - - // Transform from wrong format to correct format - const wrongFormatContentions = response.contentions as Array<{ - title?: string; - claim?: string; - points?: string[]; - evidence?: string[] | string; - analysis?: string; - reasoning?: string; - impact?: string; - }> || []; - - const transformedContentions = wrongFormatContentions.map((c, i) => ({ - id: String(i + 1), - claim: c.claim || c.title || 'Argument', - reasoning: c.reasoning || c.analysis || (Array.isArray(c.points) ? c.points.join(' ') : ''), - evidence: Array.isArray(c.evidence) ? c.evidence.join(' ') : (c.evidence || ''), - needsSource: false, - sourceQuery: '', - impact: c.impact || c.analysis || '', - })); - - // Transform rebuttals to counters - const rebuttals = (response.rebuttals as Array<{ attack?: string; response?: string }>) || []; - const counters = rebuttals.map(r => ({ - targetArgument: r.attack || '', - counterpoint: r.response || '', - rebuttal: r.response || '', - })); - - // Transform potentialWeaknesses to predictedOpponentCase - const weaknesses = (response.potentialWeaknesses as string[]) || []; - - const result: GenerateOutput = { - caseStructure: { - thesis: (response.thesis as string) || (response.title as string) || '', - framework: 'General framework', - keyDefinitions: [], - burdenOfProof: 'We must prove that ' + ((response.thesis as string) || '').slice(0, 50), - }, - contentions: transformedContentions.length > 0 ? transformedContentions : [{ - id: '1', - claim: 'Main argument', - reasoning: 'Reasoning here', - evidence: 'Evidence here', - needsSource: false, - sourceQuery: '', - impact: 'Impact here', - }], - predictedOpponentCase: { - likelyArguments: weaknesses.slice(0, 3), - strategicWeaknesses: weaknesses, - keyClashes: ['Main clash point'], - }, - counters: counters.length > 0 ? counters : [{ - targetArgument: 'Opponent argument', - counterpoint: 'Our counter', - rebuttal: 'Rebuttal text', - }], - metadata: { - side: (response.side as 'pro' | 'con') || 'pro', - format: 'custom', - generatedAt: new Date().toISOString(), - }, - }; - - // Preserve exordio if present in the original response - if (response.exordio && typeof response.exordio === 'object') { - const ex = response.exordio as Record; - result.exordio = { - hook: String(ex.hook || ''), - transition: String(ex.transition || ''), - }; - } - - return cleanGeneratedOutput(result); -} - /** * Transforms AI brainstorm response to ensure all required fields exist. * This handles cases where the AI returns incomplete structures. @@ -1239,32 +1023,13 @@ async function autosaveDiscoveredSources(sources: DiscoveredSource[], capturedVi const targetDebateId = debateId !== undefined ? debateId : activeDebateId(); // Every source belongs to one debate, so one-off research is not kept. if (!targetDebateId) return; - const retainedSources = sources.filter((source) => !source.semantic || - (source.qualityLabel !== 'weak' && source.semantic.relevance >= 0.45) || - source.semantic.lastUsedInCase === true); + const retainedSources = retainedDiscoveredSources(sources); if (retainedSources.length === 0) return; const session = useSessionStore.getState(); for (const source of retainedSources) { try { - await session.addSource({ - title: source.title, - content: source.content ?? '', - type: 'url', - origin: source.url, - folder: 'Auto-saved', - debateId: targetDebateId, - isActive: source.qualityLabel === 'strong' || source.qualityLabel === 'moderate', - metadata: { - url: source.url, - ...(source.sourceType !== undefined ? { sourceType: source.sourceType } : {}), - ...(source.qualityScore !== undefined ? { qualityScore: source.qualityScore } : {}), - ...(source.qualityLabel !== undefined ? { qualityLabel: source.qualityLabel } : {}), - ...(source.query !== undefined ? { query: source.query } : {}), - ...(source.semantic !== undefined ? { semantic: source.semantic } : {}), - capturedVia, - }, - }); + await session.addSource({ ...autoSavedSourceRow(source, capturedVia), debateId: targetDebateId }); } catch { toast({ title: i18n.t('aiClient.sourceSaveFailed'), @@ -1276,18 +1041,6 @@ async function autosaveDiscoveredSources(sources: DiscoveredSource[], capturedVi console.log(`[AI Client] Saved ${retainedSources.length} sources to library`); } -function expectedContentionCount(length: GenerateInput['length']): number { - switch (length) { - case 'standard': - return 3; - case 'comprehensive': - return 4; - case 'brief': - default: - return 2; - } -} - export async function generateCase(input: GenerateInput, config: ProviderConfig, workspaceContext?: WorkspaceToolContext, debateId?: string | null): Promise> { const inputWithLanguage = withOutputLanguage(input as unknown as Record); const response = await callSupabaseAI>('generate', inputWithLanguage, config, { diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index 7918535ea..506658bab 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -9,6 +9,7 @@ import type { AskOutput } from '../ai/schemas/ask'; import { supabase } from '@/lib/supabase/client'; import i18n from '@/i18n'; import { activeDebateId, useDebateStore, type DebateSide } from './debate-store'; +import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; interface Message { id: string; @@ -49,17 +50,6 @@ export interface SourceProvenance { semantic?: SourceSemanticMetadata; } -export interface SourceAssessmentUpdate { - key: string; - id?: string; - relevance: number; - importance: 0 | 1 | 2 | 3; - strength: 0 | 1 | 2 | 3; - evaluatedAt: string; - retained: boolean; - usedInCase?: boolean; -} - export interface Source { id: string; title: string; @@ -627,27 +617,11 @@ export const useSessionStore = create()( const candidates = previous.filter((source) => source.debateId === debateId); const updates = new Map(); for (const assessment of assessments.slice(0, 10)) { - const source = candidates.find((candidate) => candidate.id === assessment.id || candidate.id === assessment.key || candidate.origin === assessment.key || candidate.metadata?.url === assessment.key); + const source = candidates.find((candidate) => isAssessedSource(candidate, assessment)); if (!source) continue; - const prior = source.metadata?.semantic; - const usedInCase = assessment.usedInCase; updates.set(source.id, { ...source, - metadata: { - ...source.metadata, - semantic: { - relevance: assessment.relevance, - importance: assessment.importance, - strength: assessment.strength, - evaluatedAt: assessment.evaluatedAt, - useCount: (prior?.useCount ?? 0) + 1, - caseUseCount: (prior?.caseUseCount ?? 0) + (usedInCase === true ? 1 : 0), - lastUsedAt: assessment.evaluatedAt, - ...(usedInCase !== undefined - ? { lastUsedInCase: usedInCase } - : prior?.lastUsedInCase !== undefined ? { lastUsedInCase: prior.lastUsedInCase } : {}), - }, - }, + metadata: { ...source.metadata, semantic: assessedSemantic(source.metadata?.semantic, assessment) }, }); } if (updates.size === 0) return; diff --git a/src/locales/en.json b/src/locales/en.json index 890e92661..9b7fa90ca 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2490,6 +2490,41 @@ "debateMissing": "This debate no longer exists. Pick another debate and ask again.", "failed": "The coach could not answer. Try again." } + }, + "prepare": { + "saved": "Saved in this debate" + }, + "results": { + "generatedMeta": "{{side}}, generated {{when}}", + "evidenceCount_one": "{{count}} piece of evidence", + "evidenceCount_other": "{{count}} pieces of evidence", + "noEvidence": "No evidence yet", + "attackThisCase": "Attack this case", + "share": "Share", + "copyText": "Copy as text", + "loadFailed": "Could not load this result.", + "notFound": "This result is no longer saved.", + "deleteFailed": "Could not delete this result. Try again.", + "deleteAttackTitle": "Delete attack analysis?", + "deleteAttackDesc": "This removes the attack analysis from your history.", + "sourcesFailed_one": "{{count}} researched source could not be saved to your Library.", + "sourcesFailed_other": "{{count}} researched sources could not be saved to your Library.", + "exportFailed": "Could not export. Try again.", + "errors": { + "auth": "Your session has expired. Sign out and sign in again.", + "trialExpired": "Your free trial has ended. See your plan in You.", + "limitReached": "You have used today's AI requests. They refresh at midnight UTC. See your plan in You.", + "modelRestricted": "The model chosen for this tool needs Pro. See your plan in You.", + "rateLimit": "Too many requests right now. Wait a moment and try again.", + "network": "Couldn't reach the AI. Check your connection and retry.", + "timeout": "The AI took too long to respond. Please try again.", + "tooLarge": "This is too long to send. Shorten it and try again.", + "debateMissing": "This debate no longer exists. Pick another debate and try again.", + "failed": "Something went wrong. Try again." + } + }, + "attack": { + "caseRequired": "Paste or write the case you want attacked." } } } diff --git a/src/locales/es.json b/src/locales/es.json index dc4120f84..309581610 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2490,6 +2490,41 @@ "debateMissing": "Este debate ya no existe. Elige otro debate y vuelve a preguntar.", "failed": "El coach no pudo responder. Inténtalo de nuevo." } + }, + "prepare": { + "saved": "Guardado en este debate" + }, + "results": { + "generatedMeta": "{{side}}, generado {{when}}", + "evidenceCount_one": "{{count}} prueba", + "evidenceCount_other": "{{count}} pruebas", + "noEvidence": "Aún sin pruebas", + "attackThisCase": "Atacar el caso", + "share": "Compartir", + "copyText": "Copiar como texto", + "loadFailed": "No se pudo cargar este resultado.", + "notFound": "Este resultado ya no está guardado.", + "deleteFailed": "No se pudo eliminar este resultado. Inténtalo de nuevo.", + "deleteAttackTitle": "¿Eliminar el análisis de ataque?", + "deleteAttackDesc": "Esto eliminará el análisis de ataque de tu historial.", + "sourcesFailed_one": "{{count}} fuente investigada no se pudo guardar en tu Biblioteca.", + "sourcesFailed_other": "{{count}} fuentes investigadas no se pudieron guardar en tu Biblioteca.", + "exportFailed": "No se pudo exportar. Inténtalo de nuevo.", + "errors": { + "auth": "Tu sesión ha caducado. Cierra sesión y vuelve a entrar.", + "trialExpired": "Tu prueba gratuita ha terminado. Consulta tu plan en Tú.", + "limitReached": "Has usado las solicitudes de IA de hoy. Se renuevan a medianoche UTC. Consulta tu plan en Tú.", + "modelRestricted": "El modelo elegido para esta herramienta requiere Pro. Consulta tu plan en Tú.", + "rateLimit": "Demasiadas solicitudes ahora mismo. Espera un momento y vuelve a intentarlo.", + "network": "No se pudo conectar con la IA. Revisa tu conexión y vuelve a intentarlo.", + "timeout": "La IA tardó demasiado en responder. Inténtalo de nuevo.", + "tooLarge": "Es demasiado largo para enviarlo. Acórtalo y vuelve a intentarlo.", + "debateMissing": "Este debate ya no existe. Elige otro debate y vuelve a intentarlo.", + "failed": "Algo salió mal. Inténtalo de nuevo." + } + }, + "attack": { + "caseRequired": "Pega o escribe el caso que quieres atacar." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index f10f693ed..7abbb88ac 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2490,6 +2490,41 @@ "debateMissing": "Ez a vita már nem létezik. Válassz másik vitát, és kérdezz újra.", "failed": "Az edző nem tudott válaszolni. Próbáld újra." } + }, + "prepare": { + "saved": "Ebben a vitában mentve" + }, + "results": { + "generatedMeta": "{{side}}, létrehozva {{when}}", + "evidenceCount_one": "{{count}} bizonyíték", + "evidenceCount_other": "{{count}} bizonyíték", + "noEvidence": "Még nincs bizonyíték", + "attackThisCase": "Megtámadom", + "share": "Megosztás", + "copyText": "Másolás szövegként", + "loadFailed": "Nem sikerült betölteni ezt az eredményt.", + "notFound": "Ez az eredmény már nincs elmentve.", + "deleteFailed": "Nem sikerült törölni az eredményt. Próbáld újra.", + "deleteAttackTitle": "Törlöd a támadáselemzést?", + "deleteAttackDesc": "Ezzel eltávolítod a támadáselemzést az előzményeidből.", + "sourcesFailed_one": "{{count}} kutatott forrást nem sikerült a Könyvtáradba menteni.", + "sourcesFailed_other": "{{count}} kutatott forrást nem sikerült a Könyvtáradba menteni.", + "exportFailed": "Nem sikerült exportálni. Próbáld újra.", + "errors": { + "auth": "Lejárt a munkameneted. Jelentkezz ki, majd be újra.", + "trialExpired": "Lejárt az ingyenes próbaidőszakod. Az előfizetésedet a Profil lapon látod.", + "limitReached": "Elhasználtad a mai MI-kéréseidet. UTC éjfélkor újraindulnak. Az előfizetésedet a Profil lapon látod.", + "modelRestricted": "Az ehhez az eszközhöz választott modellhez Pro előfizetés kell. Az előfizetésedet a Profil lapon látod.", + "rateLimit": "Most túl sok a kérés. Várj egy kicsit, és próbáld újra.", + "network": "Nem sikerült elérni az MI-t. Ellenőrizd a kapcsolatot, és próbáld újra.", + "timeout": "Az MI túl sokáig nem válaszolt. Próbáld újra.", + "tooLarge": "Ez túl hosszú az elküldéshez. Rövidítsd le, és próbáld újra.", + "debateMissing": "Ez a vita már nem létezik. Válassz másik vitát, és próbáld újra.", + "failed": "Valami hiba történt. Próbáld újra." + } + }, + "attack": { + "caseRequired": "Illeszd be vagy írd le a megtámadandó esetet." } } }