diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0d4bd83ec..06e080bc9 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,9 +16,11 @@ 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; and the form sheets `debates.tsx` (switcher), `new-debate.tsx` and `delete-account.tsx`, 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`, full screen above the tabs. All are declared in the root `_layout.tsx`. - `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. - 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 6dc48e4bf..5cb31b52e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -27,6 +27,7 @@ "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", "i18next": "^23.16.8", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 7f6d5d9b9..8a36f209e 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -22,8 +22,14 @@ * the System theme; nothing is written. The Debate tab shows the account's * real Current Debate, and its empty state comes from answering the * current_debates read with no row. The Debate tab is shot once per side view. + * + * Ask screenshots never call a model and never save a chat: the page answers + * ai-stream from a fake stream inside the page (expo/fetch uses the page's + * 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. */ -import { chromium, type Browser, type BrowserContextOptions, type Page } from '@playwright/test'; +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 { createServer, type Server } from 'node:http'; @@ -48,7 +54,7 @@ const DEVICES = { } as const; const SCHEMES = ['light', 'dark'] as const; const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const; -const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', library: '/library', you: '/you' }; +const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', you: '/you' }; const REST = `https://${STAGING_HOST}/rest/v1`; const TYPES: Record = { @@ -142,6 +148,117 @@ async function shootDebateScreens(page: Page, prefix: string) { await shoot(page, `${prefix}-new-debate`); } +// A made-up answer in the shape of mock 07, streamed a word at a time. +const ASK_QUESTION = 'How do I answer "what about the nurse who drives in at 5 a.m."?'; +const ASK_STREAM = 'Concede the edge case, then shrink it. Three moves:\n\n1. **Exemptions exist.** Every real ban permits shift workers and carers.\n2. **Night buses follow demand.** Cities that closed their centres added routes in the first year.\n3. **Flip it.** The same nurse breathes the air on her walk from the car park.'; +const ASK_CITED = { + response: 'Two of your sources carry it.\n\n**Exemptions exist.** Every real ban permits shift workers and carers.\n\n**Night buses follow demand.** Cities that closed their centres added routes in the first year.', + citations: [ + { claim: 'Every real ban permits shift workers and carers', source: 'City exemptions policy', verified: true }, + { claim: 'Cities that closed their centres added routes in the first year', source: 'Transit report https://example.org/transit-report', verified: true }, + ], + followUpQuestions: [], + uncertainties: [], +}; +// The stream stops after this many words and stays open, so a shot can catch it mid-answer. +const STREAM_HOLD_WORDS = 34; + +/** Fakes Ask's AI calls and chat writes for this page (see the header). Call before the first navigation. */ +async function fakeAsk(page: Page, hold: boolean) { + await page.addInitScript(({ text, holdAfter }) => { + const realFetch = window.fetch.bind(window); + window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (!url.endsWith('/functions/v1/ai-stream')) return realFetch(input, init); + const words = text.split(/(?<= )/); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + for (const word of holdAfter ? words.slice(0, holdAfter) : words) { + await new Promise((resolve) => setTimeout(resolve, 15)); + controller.enqueue(encoder.encode(`event: delta\ndata: ${JSON.stringify({ text: word })}\n\n`)); + } + if (holdAfter) return; + const output = { response: text, followUpQuestions: [], uncertainties: [] }; + controller.enqueue(encoder.encode(`event: final\ndata: ${JSON.stringify({ output, meta: {} })}\n\n`)); + controller.close(); + }, + }); + return Promise.resolve(new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } })); + }; + }, { text: ASK_STREAM, holdAfter: hold ? STREAM_HOLD_WORDS : 0 }); + await page.route(`https://${STAGING_HOST}/functions/v1/ai`, (route) => + route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: ASK_CITED } }), + ); + for (const table of ['chats', 'messages']) { + await page.route(`${REST}/${table}*`, (route) => + route.request().method() === 'POST' + ? route.fulfill({ status: 201, json: { id: '00000000-0000-4000-8000-00000000c4a7' } }) + : route.continue(), + ); + } +} + +async function shootLibraryScreens(page: Page, prefix: string) { + await page.goto(`${ORIGIN}/library`); + await page.getByTestId('library-add').waitFor({ timeout: 30_000 }); + await settle(page); + await shoot(page, `${prefix}-library`); + + await page.getByTestId('library-add').click(); + await page.getByRole('menu').waitFor(); + await shoot(page, `${prefix}-library-menu`); + await page.getByRole('menuitem').first().click(); + await page.getByTestId('add-link-sheet').waitFor(); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-add-link`); + + await page.goto(`${ORIGIN}/new-note`); + await page.getByTestId('new-note-sheet').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-new-note`); + + await page.goto(`${ORIGIN}/library`); + await page.getByTestId('source-row').first().click({ timeout: 30_000 }); + await page.getByTestId('source-sheet').getByRole('heading').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-source`); +} + +async function shootAskScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const ask = async (hold: boolean) => { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} ask`); + await pinSettings(page, language); + await fakeAsk(page, hold); + await page.goto(`${ORIGIN}/ask`); + await page.getByTestId('ask-input').waitFor({ timeout: 30_000 }); + await settle(page); + return page; + }; + + const empty = await ask(false); + await shoot(empty, `${prefix}-ask-empty`); + await empty.getByTestId('ask-input').fill(ASK_QUESTION); + await empty.getByTestId('ask-send').click(); + await empty.getByTestId('ask-answer').waitFor({ timeout: 15_000 }); + await empty.getByRole('switch').first().click(); + await empty.getByTestId('ask-input').fill('Which of my sources back that up?'); + await empty.getByTestId('ask-send').click(); + await empty.getByTestId('ask-answer').nth(1).waitFor({ timeout: 15_000 }); + await empty.waitForTimeout(300); + await shoot(empty, `${prefix}-ask-answer`); + await empty.close(); + + const streaming = await ask(true); + await streaming.getByTestId('ask-input').fill(ASK_QUESTION); + await streaming.getByTestId('ask-send').click(); + await streaming.getByTestId('ask-streaming').waitFor({ timeout: 15_000 }); + await streaming.waitForTimeout(STREAM_HOLD_WORDS * 15 + 600); + await shoot(streaming, `${prefix}-ask-streaming`); + await streaming.close(); +} + async function shootEmptyDebate(page: Page, prefix: string) { await page.route(`${REST}/current_debates*`, (route) => route.fulfill({ status: 200, contentType: 'application/json', json: [] }), @@ -265,6 +382,11 @@ async function main() { throw error; }); shots += 4; + await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 5; for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { await tabPage.goto(`${ORIGIN}${route}`); await tabPage.getByRole('tablist').first().waitFor(); @@ -278,6 +400,8 @@ async function main() { await pinSettings(emptyPage, language); await shootEmptyDebate(emptyPage, prefix); shots += 1; + await shootAskScreens(signedIn, prefix, language, problems); + shots += 3; await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 64f893fcc..efb12a582 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -1,5 +1,261 @@ -import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; -export default function Screen() { - return ; +import { TextButton } from '@/components/buttons'; +import { Card, IconTile } from '@/components/card'; +import { Chip } from '@/components/chip'; +import { Icon } from '@/components/icon'; +import { LibraryHeader, type AddSourceKind } from '@/components/library-header'; +import { Screen } from '@/components/screen'; +import { SearchField } from '@/components/search-field'; +import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { confirmDestructive } from '@/lib/confirm'; +import { useDebates } from '@/lib/debate-context'; +import { fetchSources, removeSource, subscribeSourcesChanged, type Source, type SourceType } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +type Filter = 'all' | SourceType; + +// In the mock's order. Files are listed read-only: the app cannot add them yet. +const FILTERS: { value: Filter; label: string }[] = [ + { value: 'all', label: 'mobile.library.filters.all' }, + { value: 'file', label: 'mobile.library.filters.files' }, + { value: 'url', label: 'mobile.library.filters.links' }, + { value: 'text', label: 'mobile.library.filters.notes' }, +]; + +function matches(source: Source, query: string): boolean { + // The website searches titles, text and origins. + const needle = query.trim().toLocaleLowerCase(); + return !needle || [source.title, source.content, source.origin ?? ''].some((value) => value.toLocaleLowerCase().includes(needle)); +} + +/** The Current Debate's sources, as in mock 04. It shows the Current Debate even in one-off mode, like the website. */ +export default function LibraryScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { status, current } = useDebates(); + const debateId = current?.id ?? null; + + const onAdd = (kind: AddSourceKind) => router.push(kind === 'link' ? '/add-link' : '/new-note'); + + return ( + + + {status === 'loading' ? : null} + {status !== 'loading' && !debateId ? ( + + + {t('debate.libraryNeedsDebate')} + + router.push('/debates')} /> + + ) : null} + {debateId ? : null} + + ); +} + +function SourceList({ debateId }: { debateId: string }) { + const { t } = useTranslation(); + const router = useRouter(); + const { session } = useAuth(); + const { colors } = useTheme(); + const [sources, setSources] = useState(null); + const [failed, setFailed] = useState(false); + const [deleteFailed, setDeleteFailed] = useState(false); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + const loads = useRef(0); + const userId = session?.user.id ?? null; + + const load = useCallback(async () => { + if (!userId) return; + const load = ++loads.current; + try { + const next = await fetchSources(userId, debateId); + if (load !== loads.current) return; + setSources(next); + setFailed(false); + } catch (cause) { + if (load !== loads.current) return; + console.warn('Could not load sources:', cause instanceof Error ? cause.message : cause); + setFailed(true); + } + }, [userId, debateId]); + + // Sources added on the website show up when the tab comes back into view. + useFocusEffect( + useCallback(() => { + void load(); + }, [load]), + ); + useEffect(() => subscribeSourcesChanged(() => void load()), [load]); + + const visible = useMemo( + () => (sources ?? []).filter((source) => (filter === 'all' || source.type === filter) && matches(source, query)), + [sources, filter, query], + ); + + const remove = async (source: Source) => { + if (!userId) return false; + const confirmed = await confirmDestructive({ + title: t('libraryPage.deleteSourceTitle'), + message: t('libraryPage.deleteSourceDesc'), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed) return false; + setDeleteFailed(false); + try { + await removeSource(userId, source.id); + setSources((previous) => previous?.filter((item) => item.id !== source.id) ?? previous); + return true; + } catch (cause) { + console.warn('Could not delete the source:', cause instanceof Error ? cause.message : cause); + setDeleteFailed(true); + return false; + } + }; + + if (failed && !sources) { + return ( + + + {t('mobile.library.loadFailed')} + + void load()} /> + + ); + } + if (!sources) return ; + + return ( + <> + + + {FILTERS.map((option) => ( + setFilter(option.value)} + /> + ))} + + + {deleteFailed ? ( + + {t('mobile.library.deleteFailed')} + + ) : null} + + {sources.length === 0 ? ( + + + {t('libraryPage.emptyTitle')} + + + {t('libraryPage.emptyDesc')} + + + ) : visible.length === 0 ? ( + + + {t('libraryPage.noMatchingSources')} + + + {t('libraryPage.noMatchingSourcesDesc')} + + + ) : ( + + {visible.map((source, index) => ( + router.push({ pathname: '/source/[id]', params: { id: source.id } })} + onDelete={() => remove(source)} + /> + ))} + + )} + + ); } + +/** A source row; swipe left to delete, with a confirmation, as on iOS. */ +function SourceRow({ source, first, onOpen, onDelete }: { source: Source; first: boolean; onOpen: () => void; onDelete: () => Promise }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const swipeable = useRef(null); + + const remove = async () => { + const removed = await onDelete(); + if (!removed) swipeable.current?.close(); + }; + + return ( + ( + void remove()} + style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> + + + {t('common.delete')} + + + )}> + { + if (event.nativeEvent.actionName === 'delete') void remove(); + }} + onPress={onOpen} + testID="source-row" + style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}> + + + + {source.title || t('libraryPage.untitledSource')} + + + {sourceMeta(t, source)} + + + + + ); +} + +const styles = StyleSheet.create({ + loading: { marginTop: 32 }, + body: { fontSize: 15, lineHeight: 21 }, + emptyTitle: { fontSize: 17 }, + chipRow: { marginHorizontal: -20, flexGrow: 0 }, + chips: { gap: 8, paddingHorizontal: 20 }, + list: { padding: 0, gap: 0, overflow: 'hidden' }, + 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 }, + deleteAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3 }, + deleteLabel: { color: '#ffffff', fontSize: 13 }, +}); diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 937cd3d41..1ea88571b 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -1,5 +1,52 @@ -import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { useRouter } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; -export default function Screen() { - return ; +import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; +import { PRACTICE_TOOLS } from '@/components/practice-tools'; +import { ScreenTitle } from '@/components/screen-title'; +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. */ +export default function PracticeScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + return ( + + + + {PRACTICE_TOOLS.map((tool, index) => ( + (tool.key === 'ask' ? router.push('/ask') : router.push({ pathname: '/soon/[tool]', params: { 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}`)} + + + + + ))} + + + ); } + +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 }, +}); diff --git a/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx b/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx new file mode 100644 index 000000000..4318cbcd9 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx @@ -0,0 +1,13 @@ +import { useLocalSearchParams } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { PRACTICE_TOOLS } from '@/components/practice-tools'; + +/** A Practice tool that is not in the app yet. */ +export default function PracticeToolScreen() { + const { t } = useTranslation(); + const { tool } = useLocalSearchParams<{ tool: string }>(); + const entry = PRACTICE_TOOLS.find((item) => item.key === tool); + return ; +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 311548c0c..6691de4f6 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -6,7 +6,8 @@ import type { NativeStackNavigationOptions } from 'expo-router/native-stack'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; @@ -67,20 +68,25 @@ function Root() { return ( - {/* The screenshot script waits for auth-settled before it checks an offline cold start. */} - + {/* The screenshot script waits for auth-settled before it checks an offline cold start. Swipeable rows need the gesture root. */} + + + + + {/* Full screen above the tabs, as in mock 07. */} + - + ); } diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx new file mode 100644 index 000000000..37ad6a44b --- /dev/null +++ b/apps/mobile/src/app/add-link.tsx @@ -0,0 +1,153 @@ +import type { SourceImportOutput } from '@debatelab/core/ai/schemas/source-import'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MainActionButton, PrimaryButton, TextButton } from '@/components/buttons'; +import { Card } from '@/components/card'; +import { Field } from '@/components/field'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { addSource, fetchSources, importSourceFromUrl, isDuplicateOrigin } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Paste a link: the import-source Edge Function reads the page into an + * editable draft, and saving adds it to the Current Debate, as the website's + * Import from URL does. + */ +export default function AddLinkSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const { current } = useDebates(); + const [url, setUrl] = useState(''); + const [draft, setDraft] = useState(null); + const [title, setTitle] = useState(''); + const [busy, setBusy] = useState<'import' | 'save' | null>(null); + const [error, setError] = useState(''); + + const runImport = async () => { + const trimmed = url.trim(); + if (!trimmed) { + setError(t('libraryPage.importUrlRequired')); + return; + } + setBusy('import'); + setError(''); + try { + const imported = await importSourceFromUrl(trimmed); + setDraft(imported); + setTitle(imported.title); + } catch (cause) { + console.warn('Could not import the source:', cause instanceof Error ? cause.message : cause); + setError(`${t('libraryPage.importFailed')}. ${t('libraryPage.importFailedDesc')}`); + } finally { + setBusy(null); + } + }; + + const save = async () => { + const userId = session?.user.id; + if (!draft || !userId || !current || !title.trim()) return; + setBusy('save'); + setError(''); + try { + if (isDuplicateOrigin(await fetchSources(userId, current.id), draft.url)) { + setError(`${t('libraryPage.duplicateSource')}. ${t('libraryPage.duplicateSourceDesc')}`); + setBusy(null); + return; + } + await addSource(userId, { title: title.trim(), content: draft.content.trim(), type: 'url', origin: draft.url, debateId: current.id }); + router.back(); + } catch (cause) { + console.warn('Could not save the source:', cause instanceof Error ? cause.message : cause); + setError(t('mobile.library.saveFailed')); + setBusy(null); + } + }; + + return ( + + + + + {t('mobile.library.pasteLink')} + + router.back()} disabled={busy === 'save'} /> + + + {!current ? ( + + {t('debate.libraryNeedsDebate')} + + ) : ( + <> + { + setUrl(next); + setDraft(null); + }} + keyboardType="url" + autoCapitalize="none" + autoCorrect={false} + autoFocus + returnKeyType="go" + onSubmitEditing={() => void runImport()} + editable={busy === null} + /> + {!draft ? ( + void runImport()} /> + ) : ( + <> + + {t('libraryPage.importPreview')} + + + + + {`${draft.domain} · ${draft.wordCount} ${t('libraryPage.words')}`} + + {draft.excerpt ? ( + + {draft.excerpt} + + ) : null} + + void save()} /> + + )} + + )} + + {error ? ( + + {error} + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 20, paddingTop: 22, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + label: { fontSize: 14, marginLeft: 4, marginBottom: -8 }, + meta: { fontSize: 14 }, +}); diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx new file mode 100644 index 000000000..3ba8333a9 --- /dev/null +++ b/apps/mobile/src/app/ask.tsx @@ -0,0 +1,502 @@ +import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; +import { 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'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AskAnswer } from '@/components/ask-answer'; +import { AskHistory } from '@/components/ask-history'; +import { TextButton } from '@/components/buttons'; +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 { useAuth } from '@/lib/auth'; +import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; +import { useDebates } from '@/lib/debate-context'; +import { useSettings } from '@/lib/settings'; +import { fetchSources, type Source } from '@/lib/sources'; +import { radius } from '@/theme/colors'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +const NO_OPTIONS: AskOptions = { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false }; +const NEW_CHAT: ChatRecord = { chatId: null, debateId: null, persisted: 0 }; + +// The web's toggles as one row of chips above the composer (mock 07). +const OPTION_CHIPS: { key: keyof AskOptions; label: string; icon: IconName }[] = [ + { key: 'citationMode', label: 'askChat.citations', icon: 'quote' }, + { key: 'webSearch', label: 'askChat.webSearch', icon: 'globe' }, + { key: 'prosConsMode', label: 'askChat.modeProsCons', icon: 'list' }, + { key: 'argumentMapMode', label: 'askChat.modeArgMap', icon: 'map' }, +]; + +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']); + +let localId = 0; +const nextId = () => `local-${Date.now()}-${(localId += 1)}`; + +/** Ask, as in mock 07: a coaching chat that streams, grounded in the Current Debate. */ +export default function AskScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { current, oneOff, activeDebateId, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + + const [messages, setMessages] = useState([]); + const [streaming, setStreaming] = useState(null); + const [loading, setLoading] = useState(false); + const [opening, setOpening] = useState(false); + 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 [notice, setNotice] = useState(''); + const [historyOpen, setHistoryOpen] = useState(false); + const [library, setLibrary] = useState([]); + + const scroll = useRef(null); + const requests = useRef(0); + const abort = useRef(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); + const saves = useRef>(Promise.resolve()); + const activeDebateRef = useRef(activeDebateId); + activeDebateRef.current = activeDebateId; + + const setRecord = (next: ChatRecord) => { + recordRef.current = next; + setRecordState(next); + }; + + const askConfig = () => { + if (!config.current && userId) config.current = fetchAskConfig(userId); + return config.current!; + }; + + const abortActive = useCallback(() => { + requests.current += 1; + abort.current?.abort(); + abort.current = null; + }, []); + + // Leaving Ask cancels an answer still on its way, so nothing half-written is saved. + useEffect(() => () => abortActive(), [abortActive]); + + const startConversation = (messagesNext: ChatMessage[], recordNext: ChatRecord) => { + abortActive(); + conversation.current += 1; + setMessages(messagesNext); + setRecord(recordNext); + setStreaming(null); + setLoading(false); + setOpening(false); + setError(null); + setNotice(''); + }; + + // Another debate, or one-off mode, starts a fresh conversation; an open chat in the new scope stays. + const scope = oneOff ? null : current?.id ?? null; + const scopeRef = useRef(scope); + useEffect(() => { + if (scopeRef.current === scope) return; + scopeRef.current = scope; + const open = recordRef.current; + if (!(open.chatId && open.debateId === scope)) startConversation([], NEW_CHAT); + // startConversation only touches refs and setters. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scope]); + + // 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; + } + let active = true; + fetchSources(userId, chatDebateId) + .then((next) => { + if (active) setLibrary(next); + }) + .catch((cause: unknown) => console.warn('Could not load sources for citations:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + }; + }, [userId, chatDebateId]); + + // Each finished exchange is saved to the debate, as on the website. + const lastRole = messages[messages.length - 1]?.role; + useEffect(() => { + if (loading || lastRole !== 'assistant' || !userId) return; + const snapshot = messages; + const owner = conversation.current; + saves.current = saves.current.then(async () => { + if (owner !== conversation.current || recordRef.current.persisted >= snapshot.length) return; + try { + const next = await saveChat(userId, recordRef.current, snapshot, activeDebateRef.current); + if (next && owner === conversation.current) setRecord(next); + } catch (cause) { + console.warn('Could not save the chat:', cause instanceof Error ? cause.message : cause); + if (owner !== conversation.current) return; + if (cause instanceof ChatSaveError) setRecord(cause.record); + setNotice(t('debate.recordFailed')); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading, lastRole, messages, userId]); + + const send = async (text: string, history: ChatMessage[]) => { + const message = text.trim(); + if (!message || !userId) return; + abortActive(); + const request = requests.current; + const isCurrent = () => requests.current === request; + if (!isMessageWithinBudget(message)) { + setError({ kind: 'tooLarge', question: message }); + return; + } + const requestOptions = options; + const controller = new AbortController(); + abort.current = controller; + setMessages([...history, { id: nextId(), role: 'user', content: message, timestamp: Date.now() }]); + setInput(''); + setError(null); + setNotice(''); + setLoading(true); + const started = Date.now(); + + try { + const open = recordRef.current; + const debateId = open.chatId ? open.debateId : activeDebateRef.current; + const [askSettings, debateContext] = await Promise.all([ + askConfig(), + debateId ? buildAskDebateContext(userId, debateId, open.chatId) : Promise.resolve(undefined), + ]); + if (!isCurrent()) return; + const askRequest = { + message, + history: history.map((item) => ({ role: item.role, content: item.content })), + options: requestOptions, + config: askSettings, + language: outputLanguage, + debateContext, + }; + + let result: AskOutput; + if (canStream(requestOptions)) { + let received = ''; + let pending = ''; + let flush: ReturnType | null = null; + setStreaming(''); + try { + result = await streamAsk(askRequest, { + signal: controller.signal, + onDelta: (delta) => { + if (!isCurrent()) return; + received += delta; + pending += delta; + // Tokens arrive faster than the screen needs; paint every 30 ms like the website. + flush ??= setTimeout(() => { + flush = null; + const batch = pending; + pending = ''; + if (isCurrent()) setStreaming((previous) => (previous ?? '') + batch); + }, 30); + }, + }); + } catch (streamError) { + if (isAbortError(streamError) || !isCurrent()) throw streamError; + if (received) { + setNotice(`${t('ask.streamInterrupted')} ${t('ask.streamInterruptedDesc')}`); + result = { response: received.trim(), followUpQuestions: [], uncertainties: [t('ask.streamInterrupted')] }; + } else if (isFinalAskError(streamError)) { + throw streamError; + } else { + // Nothing arrived: ask once more without streaming, as the website does. + setStreaming(null); + result = await askOnce(askRequest, controller.signal); + } + } finally { + if (flush) clearTimeout(flush); + } + } else { + result = await askOnce(askRequest, controller.signal); + } + + if (!isCurrent()) return; + const output = { + ...result, + model: result.model || askSettings.model, + provider: result.provider || 'openrouter', + durationMs: result.durationMs || Date.now() - started, + generatedAt: result.generatedAt || new Date().toISOString(), + }; + setStreaming(null); + setMessages((previous) => [...previous, { id: nextId(), role: 'assistant', content: result.response, timestamp: Date.now(), output }]); + } 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); + // A missing debate was deleted elsewhere; reload the list so the app stops using it. + if (kind === 'debateMissing') void refresh(); + setError({ kind, question: message }); + } finally { + if (isCurrent()) { + setLoading(false); + setStreaming(null); + abort.current = null; + } + } + }; + + // Retrying replaces the unanswered question instead of asking it twice. + const retry = (question: string) => { + const last = messages[messages.length - 1]; + void send(question, last?.role === 'user' ? messages.slice(0, -1) : messages); + }; + + const openChat = async (chatId: string) => { + if (!userId) return; + setHistoryOpen(false); + startConversation([], NEW_CHAT); + const owner = conversation.current; + setOpening(true); + try { + const chat = await fetchChat(userId, chatId); + if (owner !== conversation.current) return; + setMessages(chat.messages); + setRecord({ chatId, debateId: chat.debateId, persisted: chat.messages.length }); + } catch (cause) { + console.warn('Could not open the chat:', cause instanceof Error ? cause.message : cause); + if (owner === conversation.current) setNotice(t('mobile.ask.openFailed')); + } finally { + if (owner === conversation.current) setOpening(false); + } + }; + + const subtitle = oneOff ? t('debate.oneOffActive') : current?.motion ?? ''; + const busy = loading || opening; + + return ( + + + router.back()} style={styles.headerButton}> + + + + + {t('nav.ask')} + + {subtitle ? ( + + {subtitle} + + ) : null} + + setHistoryOpen(true)} + style={styles.headerButton} + testID="ask-history-button"> + + + + + scroll.current?.scrollToEnd({ animated: true })}> + {messages.length === 0 && streaming === null && !busy ? ( + + + {t('askChat.startConversation')} + + + {t('askChat.startDesc')} + + + ) : null} + + {messages.map((message) => + message.role === 'user' ? ( + + + {message.content} + + + ) : ( + + ), + )} + + {streaming !== null && streaming !== '' ? : null} + {loading && !streaming ? : null} + {opening ? : null} + + {notice ? ( + + {notice} + + ) : null} + + {error ? ( + + + {t('ask.failed')} + + + {t(`mobile.ask.errors.${error.kind}`)} + + + {RETRYABLE_ERRORS.has(error.kind) ? retry(error.question)} /> : null} + {PLAN_ERRORS.has(error.kind) ? router.navigate('/you')} /> : null} + + + ) : null} + + + + + + {OPTION_CHIPS.map((chip) => ( + setOptions((previous) => ({ ...previous, [chip.key]: !previous[chip.key] }))} + /> + ))} + + + + void send(input, messages)} + style={[styles.send, { backgroundColor: colors.primary }, (busy || !input.trim()) && styles.sendDisabled]} + testID="ask-send"> + {busy ? : } + + + + + + setHistoryOpen(false)} + onPick={(chatId) => void openChat(chatId)} + onNewChat={() => { + setHistoryOpen(false); + startConversation([], NEW_CHAT); + }} + /> + + ); +} + +/** What the coach is doing while a whole answer is prepared, cycling like the website's. */ +function Thinking() { + const { t } = useTranslation(); + const [index, setIndex] = useState(0); + useEffect(() => { + const timer = setInterval(() => setIndex((value) => (value + 1) % THINKING.length), 3000); + return () => clearInterval(timer); + }, []); + return ( + + + + {t(THINKING[index])} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingBottom: 10, borderBottomWidth: StyleSheet.hairlineWidth }, + headerButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + headerText: { flex: 1, alignItems: 'center' }, + headerTitle: { fontSize: 17, lineHeight: 22 }, + headerSubtitle: { fontSize: 13.5, lineHeight: 18 }, + messages: { flex: 1 }, + messagesContent: { paddingHorizontal: 20, paddingTop: 18, paddingBottom: 24, gap: 16, width: '100%', maxWidth: 720, alignSelf: 'center' }, + empty: { alignItems: 'center', gap: 6, paddingTop: 48, paddingHorizontal: 12 }, + emptyTitle: { fontSize: 18 }, + emptyBody: { fontSize: 15.5, lineHeight: 22, textAlign: 'center' }, + bubble: { alignSelf: 'flex-end', maxWidth: '80%', paddingHorizontal: 16, paddingVertical: 11, borderTopLeftRadius: 20, borderTopRightRadius: 20, borderBottomLeftRadius: 20, borderBottomRightRadius: 6 }, + bubbleText: { fontSize: 17, lineHeight: 24 }, + opening: { marginTop: 32 }, + thinking: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + thinkingText: { fontSize: 15 }, + notice: { fontSize: 14, lineHeight: 19 }, + error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, + errorTitle: { fontSize: 16 }, + errorBody: { fontSize: 15, lineHeight: 21 }, + errorActions: { flexDirection: 'row', gap: 20 }, + composer: { borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10 }, + // The same centred column as the messages, so the composer lines up on iPad. + composerColumn: { width: '100%', maxWidth: 720, alignSelf: 'center', paddingHorizontal: 16, gap: 10 }, + chipRow: { flexGrow: 0, marginHorizontal: -16 }, + chips: { gap: 8, paddingHorizontal: 16 }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderRadius: 24, + paddingLeft: 16, + paddingRight: 6, + paddingVertical: 6, + minHeight: 48, + }, + input: { flex: 1, fontSize: 17, fontFamily: fontFamily.regular, paddingTop: 7, paddingBottom: 7, maxHeight: 120 }, + send: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + sendDisabled: { opacity: 0.45 }, +}); diff --git a/apps/mobile/src/app/new-note.tsx b/apps/mobile/src/app/new-note.tsx new file mode 100644 index 000000000..4e89c5907 --- /dev/null +++ b/apps/mobile/src/app/new-note.tsx @@ -0,0 +1,101 @@ +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MainActionButton, TextButton } from '@/components/buttons'; +import { Field } from '@/components/field'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { addSource } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +/** Write a note: a text source in the Current Debate, like the website's manual entry. */ +export default function NewNoteSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const { current } = useDebates(); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const ready = Boolean(title.trim() && content.trim()); + + const save = async () => { + const userId = session?.user.id; + if (!ready || !userId || !current) return; + setSaving(true); + setError(''); + try { + await addSource(userId, { title: title.trim(), content: content.trim(), type: 'text', debateId: current.id }); + router.back(); + } catch (cause) { + console.warn('Could not save the note:', cause instanceof Error ? cause.message : cause); + setError(t('mobile.library.saveFailed')); + setSaving(false); + } + }; + + return ( + + + + + {t('mobile.library.writeNote')} + + router.back()} disabled={saving} /> + + + {!current ? ( + + {t('debate.libraryNeedsDebate')} + + ) : ( + <> + + + {error ? ( + + {error} + + ) : null} + void save()} /> + + )} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + noteBody: { minHeight: 180, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, +}); diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx new file mode 100644 index 000000000..a9dc498db --- /dev/null +++ b/apps/mobile/src/app/source/[id].tsx @@ -0,0 +1,126 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { TextButton } from '@/components/buttons'; +import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; +import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { fetchSource, type Source } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +function webUrl(origin: string | null): string | null { + return origin && /^https?:\/\//i.test(origin) ? origin : null; +} + +/** A source, read-only: its title, where it came from and its text. */ +export default function SourceSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { id } = useLocalSearchParams<{ id: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [source, setSource] = useState(undefined); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!userId) return; + let active = true; + fetchSource(userId, id) + .then((next) => { + if (active) setSource(next); + }) + .catch((cause: unknown) => { + console.warn('Could not load the source:', cause instanceof Error ? cause.message : cause); + if (active) setFailed(true); + }); + return () => { + active = false; + }; + }, [userId, id]); + + const url = webUrl(source?.origin ?? null); + + return ( + + + + {source ? ( + + + + {sourceMeta(t, source)} + + + ) : null} + + router.back()} /> + + + {failed ? ( + + {t('mobile.library.loadFailed')} + + ) : source === undefined ? ( + + ) : source === null ? ( + + {t('mobile.library.sourceMissing')} + + ) : ( + <> + + {source.title || t('libraryPage.untitledSource')} + + {url ? ( + void WebBrowser.openBrowserAsync(url)} + style={({ pressed }) => [styles.origin, { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed]}> + + + {url.replace(/^https?:\/\/(www\.)?/i, '')} + + + + ) : source.origin ? ( + + {source.origin} + + ) : null} + + + {source.content} + + + + )} + + ); +} + +const styles = StyleSheet.create({ + content: { paddingHorizontal: 20, paddingTop: 18, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + headerText: { flex: 1 }, + kind: { flexDirection: 'row', alignItems: 'center', gap: 9 }, + meta: { fontSize: 14, flexShrink: 1 }, + loading: { marginTop: 32 }, + body: { fontSize: 15, lineHeight: 21 }, + title: { fontSize: 23, lineHeight: 29, letterSpacing: -0.4 }, + origin: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 1, borderRadius: 12, paddingHorizontal: 13, minHeight: 46 }, + originText: { flex: 1, fontSize: 15 }, + pressed: { opacity: 0.7 }, + text: { fontSize: 16, lineHeight: 24 }, +}); diff --git a/apps/mobile/src/components/ask-answer.tsx b/apps/mobile/src/components/ask-answer.tsx new file mode 100644 index 000000000..9e2205200 --- /dev/null +++ b/apps/mobile/src/components/ask-answer.tsx @@ -0,0 +1,136 @@ +import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; +import { useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import { Chip, CiteBadge } from './chip'; +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) => { + const n = index + 1; + const source = citation.source?.trim() ?? ''; + const url = firstUrl(source); + if (url) { + const found = output?.searchInfo?.sources.find((item) => item.url === url); + const named = source.replace(url, '').replace(/[\s,:;()–-]+$/u, '').replace(/^[\s,:;()–-]+/u, ''); + return { n, label: shorten(found?.title || named || hostOf(url)), url }; + } + const needle = source.toLocaleLowerCase(); + const match = needle ? library.find((item) => item.title.trim() && needle.includes(item.title.trim().toLocaleLowerCase())) : undefined; + if (match) return { n, label: shorten(match.title), sourceId: match.id }; + return { n, label: shorten(source || citation.claim) }; + }); +} + +/** One coach answer, as in mock 07, with the website's extra sections when the answer has them. */ +export function AskAnswer({ content, output, streaming, library }: { content: string; output?: AskOutput; streaming?: boolean; library: Source[] }) { + const { t } = useTranslation(); + const router = useRouter(); + const targets = useMemo(() => citationTargets(output, library), [output, library]); + const claims = useMemo(() => (output?.citations ?? []).map((citation) => citation.claim), [output]); + 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); + }; + + return ( + + {content || streaming ? ( + open(targets[n - 1])} /> + ) : null} + + {targets.length > 0 || webSources.length > 0 ? ( + + {targets.map((target) => ( + } onPress={() => open(target)} /> + ))} + {webSources.map((item) => ( + void WebBrowser.openBrowserAsync(item.url)} /> + ))} + + ) : null} + + {output?.prosConsAnalysis ? ( + +
+
+ + ) : null} + + {output?.argumentMap ? ( + + + {t('askChat.argumentMap')} + +
+
+
+
+ + ) : null} + + {output?.followUpQuestions.length ?
: null} + {output?.uncertainties.length ?
: null} + + ); +} + +function Section({ title, items, marker = '•' }: { title: string; items: string[]; marker?: string }) { + if (items.length === 0) return null; + return ( + + + {title} + + {items.map((item, index) => ( + + {`${marker} ${item}`} + + ))} + + ); +} + +const styles = StyleSheet.create({ + answer: { gap: 12 }, + chipRow: { marginHorizontal: -20, flexGrow: 0 }, + chips: { gap: 8, paddingHorizontal: 20 }, + section: { gap: 10 }, + sectionBody: { gap: 3 }, + sectionTitle: { fontSize: 14 }, + sectionItem: { fontSize: 15, lineHeight: 21 }, +}); diff --git a/apps/mobile/src/components/ask-history.tsx b/apps/mobile/src/components/ask-history.tsx new file mode 100644 index 000000000..8d8fb4c75 --- /dev/null +++ b/apps/mobile/src/components/ask-history.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { PrimaryButton, TextButton } from './buttons'; +import { Card } from './card'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { useAuth } from '@/lib/auth'; +import { fetchChats, type ChatSummary } from '@/lib/chats'; +import { formatDate } from '@/lib/dates'; +import { useTheme } from '@/theme/use-theme'; + +/** Past Ask chats in this debate (or the unsorted ones in one-off mode), in a native page sheet. */ +export function AskHistory({ + visible, + debateId, + openChatId, + onClose, + onPick, + onNewChat, +}: { + visible: boolean; + debateId: string | null; + openChatId: string | null; + onClose: () => void; + onPick: (chatId: string) => void; + onNewChat: () => void; +}) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [chats, setChats] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!visible || !userId) return; + let active = true; + setChats(null); + setFailed(false); + fetchChats(userId, debateId) + .then((next) => { + if (active) setChats(next); + }) + .catch((cause: unknown) => { + console.warn('Could not load chats:', cause instanceof Error ? cause.message : cause); + if (active) setFailed(true); + }); + return () => { + active = false; + }; + }, [visible, userId, debateId]); + + return ( + + + + + {t('askChat.savedChats')} + + + + + {failed ? ( + + {t('mobile.ask.historyFailed')} + + ) : chats === null ? ( + + ) : chats.length === 0 ? ( + + + {t('askChat.noSavedChats')} + + + {t('askChat.noSavedChatsDesc')} + + + ) : ( + + {chats.map((chat, index) => ( + onPick(chat.id)} + style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + {chat.title} + + + {`${formatDate(new Date(chat.createdAt).toISOString())} · ${t('askChat.messageCount', { count: chat.messageCount })}`} + + + {chat.id === openChatId ? : null} + + ))} + + )} + + + ); +} + +const styles = StyleSheet.create({ + content: { paddingHorizontal: 20, paddingTop: 22, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + loading: { marginTop: 24 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, + rowText: { flex: 1, gap: 3 }, + rowTitle: { fontSize: 16, lineHeight: 21 }, + meta: { fontSize: 14 }, + pressed: { opacity: 0.6 }, +}); diff --git a/apps/mobile/src/components/chip.tsx b/apps/mobile/src/components/chip.tsx new file mode 100644 index 000000000..6680dcb3e --- /dev/null +++ b/apps/mobile/src/components/chip.tsx @@ -0,0 +1,80 @@ +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; +import { Text } from './text'; + +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** The mocks' pill chip: filters in Library, options and sources in Ask. */ +export function Chip({ + label, + selected, + icon, + leading, + onPress, + accessibilityRole = 'button', + testID, +}: { + label: string; + selected?: boolean; + icon?: IconName; + leading?: ReactNode; + onPress: () => void; + accessibilityRole?: 'button' | 'switch' | 'tab'; + testID?: string; +}) { + const { colors } = useTheme(); + const foreground = selected ? colors.chipSelectedForeground : colors.secondary; + return ( + [ + styles.chip, + selected + ? { backgroundColor: colors.tile, borderColor: colors.chipSelectedBorder } + : { backgroundColor: colors.input, borderColor: colors.border }, + pressed && styles.pressed, + ]}> + {leading} + {icon ? : null} + + {label} + + + ); +} + +/** The small numbered square used for citations, inline and in source chips. */ +export function CiteBadge({ n }: { n: number }) { + const { colors } = useTheme(); + return ( + + + {n} + + + ); +} + +const styles = StyleSheet.create({ + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderWidth: 1, + borderRadius: radius.pill, + paddingHorizontal: 13, + minHeight: 36, + maxWidth: 280, + }, + label: { fontSize: 14.5, flexShrink: 1 }, + pressed: { opacity: 0.7 }, + cite: { minWidth: 20, height: 20, borderRadius: 6, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' }, + citeText: { fontSize: 12, lineHeight: 15 }, +}); diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index b49766ec8..c3c0a30fd 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -22,6 +22,20 @@ export const iconPaths = { search: '', check: '', x: '', + chevronLeft: '', + file: '', + link: '', + note: '', + externalLink: '', + trash: '', + shield: '', + help: '', + timer: '', + history: '', + quote: '', + list: '', + map: '', + arrowUp: '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/components/library-header.tsx b/apps/mobile/src/components/library-header.tsx new file mode 100644 index 000000000..1c96e76de --- /dev/null +++ b/apps/mobile/src/components/library-header.tsx @@ -0,0 +1,35 @@ +import { Stack } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +import { useTheme } from '@/theme/use-theme'; + +export type AddSourceKind = 'link' | 'note'; + +/** + * The Library's large title and its round plus button, which opens a native + * menu. "Scan pages" and "Choose a file" from the mock are not in the app yet. + */ +export function LibraryHeader({ title, onAdd, disabled }: { title: string; onAdd: (kind: AddSourceKind) => void; disabled: boolean }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + <> + + + + onAdd('link')}> + {t('mobile.library.pasteLink')} + + onAdd('note')}> + {t('mobile.library.writeNote')} + + + + + ); +} diff --git a/apps/mobile/src/components/library-header.web.tsx b/apps/mobile/src/components/library-header.web.tsx new file mode 100644 index 000000000..a5bdda338 --- /dev/null +++ b/apps/mobile/src/components/library-header.web.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; +import type { AddSourceKind } from './library-header'; +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +/** The web preview has no native header or menu, so it draws the mock's title row and menu. */ +export function LibraryHeader({ title, onAdd, disabled }: { title: string; onAdd: (kind: AddSourceKind) => void; disabled: boolean }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [open, setOpen] = useState(false); + const items: { kind: AddSourceKind; label: string; icon: IconName }[] = [ + { kind: 'link', label: t('mobile.library.pasteLink'), icon: 'link' }, + { kind: 'note', label: t('mobile.library.writeNote'), icon: 'note' }, + ]; + return ( + + + {title} + + setOpen((value) => !value)} + testID="library-add" + style={[styles.plus, { backgroundColor: colors.primary }, disabled && styles.disabled]}> + + + {open ? ( + + {items.map((item, index) => ( + { + setOpen(false); + onAdd(item.kind); + }} + style={({ pressed }) => [ + styles.item, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && { backgroundColor: colors.line }, + ]}> + {item.label} + + + ))} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8, marginBottom: 2, zIndex: 10 }, + title: { fontSize: 34, letterSpacing: -1, lineHeight: 41 }, + plus: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, + disabled: { opacity: 0.5 }, + menu: { + position: 'absolute', + top: 50, + right: 0, + width: 252, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + overflow: 'hidden', + boxShadow: '0 14px 40px -10px rgba(15,23,42,0.4)', + }, + item: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 17, minHeight: 52 }, + itemLabel: { fontSize: 17 }, +}); diff --git a/apps/mobile/src/components/markdown-view.tsx b/apps/mobile/src/components/markdown-view.tsx new file mode 100644 index 000000000..de2b0a15c --- /dev/null +++ b/apps/mobile/src/components/markdown-view.tsx @@ -0,0 +1,151 @@ +import * as WebBrowser from 'expo-web-browser'; +import { Fragment, useMemo, type ReactNode } from 'react'; +import { StyleSheet, Text as NativeText, View } from 'react-native'; + +import { CiteBadge } from './chip'; +import { Text } from './text'; + +import { attachCitations, parseMarkdown, type Block, type Inline } from '@/lib/markdown'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +/** + * An Ask answer as readable text, as in mock 07: paragraphs, bold lead-ins, + * lists and numbered citation chips. `claims` are the citations' claims in + * order; a chip is placed where the answer quotes one. While streaming, a + * caret follows the last word. + */ +export function MarkdownView({ + source, + claims = [], + streaming, + onCite, +}: { + source: string; + claims?: string[]; + streaming?: boolean; + onCite?: (n: number) => void; +}) { + const { colors } = useTheme(); + const blocks = useMemo(() => attachCitations(parseMarkdown(source), claims), [source, claims]); + const caret = ; + + const inline = (nodes: Inline[], key = ''): ReactNode[] => + nodes.map((node, index) => { + const id = `${key}${index}`; + switch (node.type) { + case 'text': + return node.text; + case 'bold': + return ( + + {inline(node.children, `${id}.`)} + + ); + case 'italic': + return ( + + {inline(node.children, `${id}.`)} + + ); + case 'code': + return ( + + {node.text} + + ); + case 'link': + return ( + void WebBrowser.openBrowserAsync(node.url)} style={{ color: colors.primary, textDecorationLine: 'underline' }}> + {node.text} + + ); + case 'cite': + return ( + onCite(node.n) : undefined}> + {' '} + + + ); + } + }); + + const last = blocks.length - 1; + const render = (block: Block, index: number) => { + const tail = streaming && index === last ? caret : null; + switch (block.type) { + case 'paragraph': + return ( + + {inline(block.inline)} + {tail} + + ); + case 'heading': + return ( + + {inline(block.inline)} + {tail} + + ); + case 'quote': + return ( + + + {inline(block.inline)} + {tail} + + + ); + case 'code': + return ( + + {block.text} + {tail} + + ); + case 'rule': + return ; + case 'list': + return ( + + {block.items.map((item, itemIndex) => ( + + + {block.ordered ? `${block.start + itemIndex}.` : '•'} + + + {inline(item, `${itemIndex}:`)} + {streaming && index === last && itemIndex === block.items.length - 1 ? caret : null} + + + ))} + + ); + } + }; + + return ( + + {blocks.length === 0 && streaming ? {caret} : blocks.map((block, index) => {render(block, index)})} + + ); +} + +const styles = StyleSheet.create({ + root: { gap: 9 }, + paragraph: { fontSize: 17, lineHeight: 25 }, + h2: { fontSize: 19, lineHeight: 25 }, + h3: { fontSize: 17 }, + italic: { fontStyle: 'italic' }, + inlineCode: { fontFamily: 'Courier', fontSize: 15 }, + quote: { borderLeftWidth: 3, paddingLeft: 12 }, + codeBlock: { borderRadius: 10, padding: 12 }, + code: { fontFamily: 'Courier', fontSize: 14, lineHeight: 20 }, + rule: { height: 1, marginVertical: 4 }, + list: { gap: 6 }, + item: { flexDirection: 'row', gap: 8 }, + marker: { minWidth: 18 }, + itemText: { flex: 1 }, + caret: { width: 9, height: 18, borderRadius: 2, marginLeft: 3, transform: [{ translateY: 3 }] }, +}); diff --git a/apps/mobile/src/components/placeholder-screen.tsx b/apps/mobile/src/components/placeholder-screen.tsx index a47875eb2..41e3648b5 100644 --- a/apps/mobile/src/components/placeholder-screen.tsx +++ b/apps/mobile/src/components/placeholder-screen.tsx @@ -2,21 +2,22 @@ import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; import { Card, IconTile } from './card'; +import type { IconName } from './icon-paths'; import { ScreenTitle } from './screen-title'; import { Screen } from './screen'; import { TABS } from './tabs'; import { Text } from './text'; -/** An honest stand-in for a tab whose screens have not reached the app yet. */ -export function PlaceholderScreen({ tab }: { tab: 'prepare' | 'practice' | 'library' }) { +/** An honest stand-in for a tab, or a tool in it, whose screens have not reached the app yet. */ +export function PlaceholderScreen({ tab, title, icon }: { tab: 'prepare' | 'practice'; title?: string; icon?: IconName }) { const { t } = useTranslation(); - const { icon, label } = TABS.find((entry) => entry.name === `(${tab})`)!; + const entry = TABS.find((item) => item.name === `(${tab})`)!; return ( - + - + {t('mobile.placeholder.title')} diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts new file mode 100644 index 000000000..64691553a --- /dev/null +++ b/apps/mobile/src/components/practice-tools.ts @@ -0,0 +1,12 @@ +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. */ +export const PRACTICE_TOOLS = [ + { key: 'attack', icon: 'shield' }, + { key: 'ask', icon: 'chat' }, + { key: 'crossfire', icon: 'help' }, + { key: 'sparring', icon: 'swords' }, + { key: 'timer', icon: 'timer' }, +] as const satisfies readonly { key: string; icon: IconName }[]; + +export type PracticeTool = (typeof PRACTICE_TOOLS)[number]['key']; diff --git a/apps/mobile/src/components/search-field.tsx b/apps/mobile/src/components/search-field.tsx new file mode 100644 index 000000000..f230c87d6 --- /dev/null +++ b/apps/mobile/src/components/search-field.tsx @@ -0,0 +1,33 @@ +import { StyleSheet, TextInput, View } from 'react-native'; + +import { Icon } from './icon'; + +import { radius } from '@/theme/colors'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +/** The mocks' grey search field. */ +export function SearchField({ value, onChangeText, placeholder }: { value: string; onChangeText: (text: string) => void; placeholder: string }) { + const { colors } = useTheme(); + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + search: { flexDirection: 'row', alignItems: 'center', gap: 9, borderRadius: radius.field, paddingHorizontal: 13, minHeight: 46 }, + input: { flex: 1, fontSize: 16.5, fontFamily: fontFamily.regular, paddingVertical: 10 }, +}); diff --git a/apps/mobile/src/components/source-display.ts b/apps/mobile/src/components/source-display.ts new file mode 100644 index 000000000..07058b903 --- /dev/null +++ b/apps/mobile/src/components/source-display.ts @@ -0,0 +1,16 @@ +import type { TFunction } from 'i18next'; + +import type { IconName } from './icon-paths'; + +import { formatRelativeDate } from '@/lib/dates'; +import type { Source, SourceType } from '@/lib/sources'; + +export const SOURCE_ICONS: Record = { url: 'link', text: 'note', file: 'file' }; + +/** "Link, cited in 2 results" when the website counted uses, otherwise "Note, added 3 days ago". */ +export function sourceMeta(t: TFunction, source: Source): string { + const type = t(`mobile.library.types.${source.type}`); + return source.useCount > 0 + ? t('mobile.library.metaCited', { type, count: source.useCount }) + : t('mobile.library.metaAdded', { type, when: formatRelativeDate(source.createdAt) }); +} diff --git a/apps/mobile/src/lib/ask-config.ts b/apps/mobile/src/lib/ask-config.ts new file mode 100644 index 000000000..9395dd03a --- /dev/null +++ b/apps/mobile/src/lib/ask-config.ts @@ -0,0 +1,94 @@ +import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { + AUTO_FAST, + AUTO_PRO, + DEFAULT_MODEL, + DEFAULT_PRO_MODEL, + getModelCatalogEntry, + isAutoModelSelection, + isReasoningEffort, + type ReasoningEffort, +} from '@debatelab/core/contract/model-catalog'; + +import { supabase } from './supabase'; + +/** + * The Ask 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 { + model: string; + temperature: number; + reasoningEffort: ReasoningEffort; +} + +function defaultConfig(model: string = DEFAULT_MODEL): AskConfig { + const { temperature, reasoningEffort } = getDefaultFeatureConfigs(model).ask; + return { model, temperature, reasoningEffort: reasoningEffort ?? 'low' }; +} + +const LEGACY_MODEL_SUCCESSORS: Record = { + 'gpt-5.5': DEFAULT_MODEL, + 'gpt-5.4': DEFAULT_MODEL, + 'gpt-5.4-mini': DEFAULT_MODEL, + 'gpt-4o': DEFAULT_MODEL, + 'gpt-4o-mini': DEFAULT_MODEL, + 'claude-opus-4-7': DEFAULT_PRO_MODEL, + 'claude-sonnet-4-6': DEFAULT_PRO_MODEL, + 'claude-haiku-4-5-20251001': DEFAULT_MODEL, + 'gemini-3.1-pro-preview': 'google/gemini-3.8-flash', + 'gemini-3.5-flash': 'google/gemini-3.8-flash', + 'gemini-3-flash-preview': 'google/gemini-3.8-flash', + 'gemini-3.1-flash-lite-preview': 'google/gemini-3.8-flash', +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; +} + +function normalizeModelId(value: unknown, fallback: string): string { + if (typeof value !== 'string' || !value.trim()) return fallback; + const model = value.trim(); + if (isAutoModelSelection(model)) return model; + if (getModelCatalogEntry(model)) return model; + return LEGACY_MODEL_SUCCESSORS[model] ?? DEFAULT_MODEL; +} + +function normalizeConfig(value: unknown, fallback: AskConfig, allowProModels: boolean): AskConfig { + const candidate = asRecord(value); + const model = normalizeModelId(candidate?.model, fallback.model); + return { + model: !allowProModels && model === AUTO_PRO + ? AUTO_FAST + : !allowProModels && getModelCatalogEntry(model)?.isProOnly + ? DEFAULT_MODEL + : model, + temperature: typeof candidate?.temperature === 'number' && Number.isFinite(candidate.temperature) + ? candidate.temperature + : fallback.temperature, + reasoningEffort: isReasoningEffort(candidate?.reasoningEffort) ? candidate.reasoningEffort : fallback.reasoningEffort, + }; +} + +export async function fetchAskConfig(userId: string): 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(); + // 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 + .from('subscriptions') + .select('status, current_period_end') + .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); +} diff --git a/apps/mobile/src/lib/ask-context.ts b/apps/mobile/src/lib/ask-context.ts new file mode 100644 index 000000000..7f7d85af3 --- /dev/null +++ b/apps/mobile/src/lib/ask-context.ts @@ -0,0 +1,120 @@ +import { + boundOutputDigests, + digestOutput, + isDebateContextOutputKind, + MAX_CONTEXT_OUTPUTS, + MAX_CONTEXT_SOURCES, + selectDefaultOutputs, + type DebateContextOutputKind, + type DebateContextRequest, + type DebateContextSide, + type DebateOutputCandidate, + type DebateOutputRef, +} from '@debatelab/core/contract/debate-context'; + +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; + * if they cannot be read, the automatic selection is used. The server loads + * and checks the text. + */ + +const RESULT_TABLES = { + case: 'cases', + attack: 'attacks', + brainstorm: 'brainstorms', + factoids: 'factoids', + chat: 'chats', + crossfire: 'crossfires', +} as const satisfies Partial>; +type ResultKind = keyof typeof RESULT_TABLES; +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; +} + +function rowsOf(result: { data: unknown; error: { message: string } | null }): T[] { + if (result.error) throw new Error(result.error.message); + return (result.data ?? []) as T[]; +} + +/** + * The stored text of one saved result, in the shape digestOutput reads. Like + * every read here it filters on the user, except messages, which have no user + * column and belong to a chat from the user's own list. + */ +async function outputData(userId: string, ref: DebateOutputRef): Promise { + if (ref.kind === 'chat') { + const result = await supabase.from('messages').select('role,content').eq('chat_id', ref.id).order('created_at', { ascending: true }); + return rowsOf(result); + } + if (ref.kind === 'sparring') { + const result = await supabase + .from('sparring_sessions') + .select('user_side,final_evaluation') + .eq('user_id', userId) + .eq('id', ref.id) + .maybeSingle(); + if (result.error) throw new Error(result.error.message); + const row = result.data as { user_side?: unknown; final_evaluation?: unknown } | null; + return { userSide: row?.user_side, scorecard: row?.final_evaluation }; + } + const result = await supabase.from(RESULT_TABLES[ref.kind]).select('output').eq('user_id', userId).eq('id', ref.id).maybeSingle(); + if (result.error) throw new Error(result.error.message); + return (result.data as { output?: unknown } | null)?.output; +} + +/** `excludeChatId` is the chat open in Ask: it is already the conversation. */ +export async function buildAskDebateContext(userId: string, debateId: string, excludeChatId: string | 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), + supabase.from('sparring_sessions').select('id,user_side,updated_at').eq('user_id', userId).eq('debate_id', debateId), + ...RESULT_KINDS.map((kind) => + supabase.from(RESULT_TABLES[kind]).select('id,side,created_at').eq('user_id', userId).eq('debate_id', debateId)), + ]); + + const sourceIds = rowsOf<{ id: string; is_active: boolean }>(sources) + .filter((source) => source.is_active && UUID.test(source.id)) + .slice(0, MAX_CONTEXT_SOURCES) + .map((source) => source.id); + + // Pins are optional; the website falls back to the automatic selection when they cannot be read. + const pinnedKeys = new Set( + pins.error ? [] : ((pins.data ?? []) as { item_kind: unknown; item_id: unknown }[]).flatMap((row) => + isDebateContextOutputKind(row.item_kind) && typeof row.item_id === 'string' ? [`${row.item_kind}:${row.item_id}`] : []), + ); + + const candidates: DebateOutputCandidate[] = []; + RESULT_KINDS.forEach((kind, index) => { + for (const row of rowsOf<{ id: string; side: unknown; created_at: string }>(results[index])) { + if (!UUID.test(row.id) || (kind === 'chat' && row.id === excludeChatId)) continue; + candidates.push({ kind, id: row.id, side: sideOf(row.side), createdAt: new Date(row.created_at).getTime(), pinned: pinnedKeys.has(`${kind}:${row.id}`) }); + } + }); + for (const row of rowsOf<{ id: string; user_side: unknown; updated_at: string }>(sparring)) { + if (!UUID.test(row.id)) continue; + 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); + // 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, + sourceIds, + outputs: sent.filter((ref) => !empty.has(`${ref.kind}:${ref.id}`)), + }; +} diff --git a/apps/mobile/src/lib/ask.test.ts b/apps/mobile/src/lib/ask.test.ts new file mode 100644 index 000000000..b232d1acd --- /dev/null +++ b/apps/mobile/src/lib/ask.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; + +const session = vi.hoisted(() => ({ resolve: (_token: string) => {} })); +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock('./supabase', () => ({ + supabase: { auth: { getSession: () => new Promise((resolve) => (session.resolve = (token) => resolve({ data: { session: { access_token: token } } }))) } }, +})); +vi.mock('expo/fetch', () => ({ fetch: fetchMock })); + +import { askOnce } from './ask'; + +describe('askOnce', () => { + it('never sends the request when Ask is left while the session is still being read', async () => { + const controller = new AbortController(); + const request = askOnce({ + message: 'Why ban homework?', + history: [], + options: { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false }, + config: { model: 'test-model', temperature: 0.3, reasoningEffort: 'low' }, + language: 'en', + }, controller.signal); + controller.abort(); + session.resolve('token'); + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/ask.ts b/apps/mobile/src/lib/ask.ts new file mode 100644 index 000000000..b1174cdc3 --- /dev/null +++ b/apps/mobile/src/lib/ask.ts @@ -0,0 +1,287 @@ +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'; + +/** + * Ask requests, sent the way the website sends them (src/lib/ai/streaming.ts + * and askChat in src/lib/ai/client.ts). Plain questions stream from the + * ai-stream Edge Function. ai-stream refuses Citations, Web search, Pros/Cons + * and Argument map, so with any of them on the answer comes whole from the ai + * 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; + webSearch: boolean; + prosConsMode: boolean; + argumentMapMode: boolean; +} + +export interface HistoryMessage { + role: 'user' | 'assistant'; + content: string; +} + +export interface AskRequest { + message: string; + history: HistoryMessage[]; + options: AskOptions; + config: AskConfig; + 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; +} + +/** Streaming serves plain questions only, as on the website (isAskStreamingSupported). */ +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; + let end = 0; + for (const char of value) { + const size = utf8Length(char); + if (bytes + size > maxBytes) break; + bytes += size; + end += char.length; + } + return value.slice(0, end); +} + +export function isMessageWithinBudget(message: string): boolean { + return utf8Length(message) <= MAX_MESSAGE_BYTES; +} + +/** The last eight messages within the byte budget, as the website's boundAskHistory. */ +export function boundHistory(history: HistoryMessage[]): HistoryMessage[] { + let remaining = MAX_HISTORY_TOTAL_BYTES; + return history.slice(-MAX_HISTORY_ITEMS).reverse().flatMap((message) => { + if (remaining <= 0) return []; + const content = truncateUtf8(message.content, Math.min(MAX_HISTORY_CONTENT_BYTES, remaining)); + remaining -= utf8Length(content); + return content ? [{ ...message, content }] : []; + }).reverse(); +} + +function requestInput(request: AskRequest) { + if (!isMessageWithinBudget(request.message)) throw new AskError('tooLarge', 'Message is too large'); + return { + message: request.message, + history: boundHistory(request.history), + mode: 'general', + citationMode: request.options.citationMode, + webSearch: request.options.webSearch, + prosConsMode: request.options.prosConsMode, + argumentMapMode: request.options.argumentMapMode, + language: request.language, + }; +} + +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'); + 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 = { + feature: 'ask', + input: requestInput(request), + ...(request.debateContext ? { debateContext: request.debateContext } : {}), + provider: PROVIDER, + model: request.config.model, + 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'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let resolvedModel = request.config.model; + let output: AskOutput | null = null; + + const consume = (events: ParsedSSEEvent[]) => { + for (const event of events) { + const data = event.data && typeof event.data === 'object' ? (event.data as Record) : null; + if (event.event === 'delta' && data && 'text' in data) { + const text = String(data.text ?? ''); + if (text) handlers.onDelta(text); + } + if (event.event === 'final' && data && 'output' in data && !output) { + const meta = data.meta && typeof data.meta === 'object' ? (data.meta as Record) : undefined; + if (typeof meta?.resolvedModel === 'string') resolvedModel = meta.resolvedModel; + output = parseOutput(data.output, resolvedModel); + } + if (event.event === 'error') { + throw errorFrom(typeof data?.status === 'number' ? data.status : undefined, data, 'Streaming request failed'); + } + } + }; + + const cancel = () => void reader.cancel().catch((): undefined => undefined); + handlers.signal?.addEventListener('abort', cancel); + try { + while (true) { + const { done, value } = await reader.read(); + if (handlers.signal?.aborted) throw abortError(); + if (done) break; + const parsed = consumeSSEChunk(buffer, decoder.decode(value, { stream: true })); + buffer = parsed.buffer; + consume(parsed.events); + } + consume(consumeSSEChunk('', `${buffer}\n\n`).events); + if (!output) throw new AskError('failed', 'Streaming request ended before a final response was received'); + return output; + } finally { + handlers.signal?.removeEventListener('abort', cancel); + cancel(); + } + }); +} + +/** Asks the ai Edge Function for a whole answer, with the options the stream cannot serve. */ +export function askOnce(request: AskRequest, signal?: AbortSignal): Promise { + const body = { + feature: 'ask', + input: requestInput(request), + provider: PROVIDER, + model: request.config.model, + temperature: request.config.temperature, + reasoningEffort: request.config.reasoningEffort, + // Servers from before Current Debate context read only this field. + sourceIds: request.debateContext?.sourceIds ?? [], + ...(request.debateContext ? { debateContext: request.debateContext } : {}), + }; + return post('ai', body, signal, async (response) => { + 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); + }); +} diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts new file mode 100644 index 000000000..7f2d76fbf --- /dev/null +++ b/apps/mobile/src/lib/chats.ts @@ -0,0 +1,123 @@ +import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; + +import { supabase } from './supabase'; + +/** + * Ask conversations in the website's chats and messages tables, written the + * way its session store records them (syncAskChatNow in + * src/lib/stores/session-store.ts): a new chat needs a debate, a loaded chat + * keeps the debate it lives in, and only finished exchanges are saved. + */ + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: number; + output?: AskOutput & Record; +} + +export interface ChatSummary { + id: string; + title: string; + createdAt: number; + messageCount: number; +} + +/** Where the open conversation is saved. `persisted` counts the messages already written. */ +export interface ChatRecord { + chatId: string | null; + debateId: string | null; + persisted: number; +} + +/** A failed message write, with the record to keep: the chat may have been created before it failed. */ +export class ChatSaveError extends Error { + constructor(message: string, readonly record: ChatRecord) { + super(message); + this.name = 'ChatSaveError'; + } +} + +interface MessageRow { + id: string; + role: 'user' | 'assistant'; + content: string; + output: unknown; + created_at: string; +} + +/** + * Every chat 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. + * Messages have no user column and are reached through their chat. + */ + +/** Chats in a debate, or the unsorted ones in one-off mode, newest first, as the website's saved chats list. */ +export async function fetchChats(userId: string, debateId: string | null): Promise { + const query = supabase + .from('chats') + .select('id,title,created_at,messages(count)') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; title: string; created_at: string; messages: { count: number }[] }[]).map((row) => ({ + id: row.id, + title: row.title, + createdAt: new Date(row.created_at).getTime(), + messageCount: row.messages[0]?.count ?? 0, + })); +} + +export async function fetchChat(userId: string, chatId: string): Promise<{ debateId: string | null; messages: ChatMessage[] }> { + const [chat, messages] = await Promise.all([ + supabase.from('chats').select('debate_id').eq('user_id', userId).eq('id', chatId).single(), + supabase.from('messages').select('id,role,content,output,created_at').eq('chat_id', chatId).order('created_at', { ascending: true }), + ]); + if (chat.error) throw new Error(chat.error.message); + if (messages.error) throw new Error(messages.error.message); + return { + debateId: (chat.data as { debate_id: string | null }).debate_id ?? null, + messages: ((messages.data ?? []) as MessageRow[]).map((row) => ({ + id: row.id, + role: row.role, + content: row.content, + timestamp: new Date(row.created_at).getTime(), + output: row.output && typeof row.output === 'object' ? (row.output as ChatMessage['output']) : undefined, + })), + }; +} + +/** + * Writes the messages not saved yet, creating the chat first when needed. + * Returns the record to keep, or null when there is nowhere to save: a new + * conversation in one-off mode stays unsaved, as on the website. + */ +export async function saveChat(userId: string, record: ChatRecord, messages: ChatMessage[], activeDebateId: string | null): Promise { + if (messages.length === 0 || record.persisted >= messages.length) return record; + let { chatId, debateId } = record; + if (!chatId) { + if (!activeDebateId) return null; + const title = messages.find((message) => message.role === 'user')?.content.slice(0, 60) || 'Chat'; + const { data, error } = await supabase + .from('chats') + .insert({ user_id: userId, debate_id: activeDebateId, side: 'neutral', title }) + .select('id') + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not save the chat'); + chatId = (data as { id: string }).id; + debateId = activeDebateId; + } + const pending = messages.slice(record.persisted); + const { error } = await supabase.from('messages').insert(pending.map((message) => ({ + chat_id: chatId, + role: message.role, + content: message.content, + output: message.output || null, + created_at: new Date(message.timestamp).toISOString(), + }))); + // The chat exists now; keep it so the next save adds these messages to it instead of creating another. + if (error) throw new ChatSaveError(error.message, { chatId, debateId, persisted: record.persisted }); + return { chatId, debateId, persisted: messages.length }; +} diff --git a/apps/mobile/src/lib/confirm.ts b/apps/mobile/src/lib/confirm.ts new file mode 100644 index 000000000..a12483994 --- /dev/null +++ b/apps/mobile/src/lib/confirm.ts @@ -0,0 +1,11 @@ +import { Alert } from 'react-native'; + +/** A native alert with Cancel and a destructive action. Resolves true when the action is chosen. */ +export function confirmDestructive(options: { title: string; message: string; confirm: string; cancel: string }): Promise { + return new Promise((resolve) => { + Alert.alert(options.title, options.message, [ + { text: options.cancel, style: 'cancel', onPress: () => resolve(false) }, + { text: options.confirm, style: 'destructive', onPress: () => resolve(true) }, + ], { cancelable: true, onDismiss: () => resolve(false) }); + }); +} diff --git a/apps/mobile/src/lib/confirm.web.ts b/apps/mobile/src/lib/confirm.web.ts new file mode 100644 index 000000000..2aeeea491 --- /dev/null +++ b/apps/mobile/src/lib/confirm.web.ts @@ -0,0 +1,4 @@ +/** react-native-web's Alert does nothing, so the web preview asks with the browser's dialog. */ +export function confirmDestructive(options: { title: string; message: string; confirm: string; cancel: string }): Promise { + return Promise.resolve(window.confirm(`${options.title}\n\n${options.message}`)); +} diff --git a/apps/mobile/src/lib/markdown.test.ts b/apps/mobile/src/lib/markdown.test.ts new file mode 100644 index 000000000..dfce29127 --- /dev/null +++ b/apps/mobile/src/lib/markdown.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { attachCitations, parseInline, parseMarkdown } from './markdown'; + +describe('parseMarkdown', () => { + it('reads the coach answer shape: lead paragraph, bold lead-ins in a list, citation markers', () => { + const blocks = parseMarkdown('Concede the edge case.\n\n1. **Exemptions exist.** Real bans permit carers. [1]\n2. **Flip it.** Same nurse.\n\n- one\n continued'); + expect(blocks).toEqual([ + { type: 'paragraph', inline: [{ type: 'text', text: 'Concede the edge case.' }] }, + { + type: 'list', ordered: true, start: 1, items: [ + [{ type: 'bold', children: [{ type: 'text', text: 'Exemptions exist.' }] }, { type: 'text', text: ' Real bans permit carers. ' }, { type: 'cite', n: 1 }], + [{ type: 'bold', children: [{ type: 'text', text: 'Flip it.' }] }, { type: 'text', text: ' Same nurse.' }], + ], + }, + { type: 'list', ordered: false, start: 1, items: [[{ type: 'text', text: 'one continued' }]] }, + ]); + }); + + it('renders an unfinished bold run as bold while the answer streams', () => { + expect(parseInline('Night **buses follow')).toEqual([ + { type: 'text', text: 'Night ' }, + { type: 'bold', children: [{ type: 'text', text: 'buses follow' }] }, + ]); + }); + + it('keeps an unfinished code fence and does not treat snake_case or 2 * 3 as italics', () => { + expect(parseMarkdown('```\nconst a = 1')).toEqual([{ type: 'code', text: 'const a = 1' }]); + expect(parseInline('use snake_case_names and 2 * 3 * 4')).toEqual([{ type: 'text', text: 'use snake_case_names and 2 * 3 * 4' }]); + }); + + it('only links http(s) URLs', () => { + expect(parseInline('[site](https://example.org/a) [x](javascript:alert(1))')).toEqual([ + { type: 'link', text: 'site', url: 'https://example.org/a' }, + { type: 'text', text: ' [x](javascript:alert(1))' }, + ]); + }); +}); + +describe('attachCitations', () => { + it('puts a citation after the block that quotes its claim, once, and skips claims it cannot find', () => { + const blocks = parseMarkdown('Every real ban permits shift workers and carers.\n\nNothing else here.'); + const cited = attachCitations(blocks, ['Every real ban permits shift workers and carers', 'A claim the answer never makes at all']); + expect(cited[0]).toEqual({ type: 'paragraph', inline: [{ type: 'text', text: 'Every real ban permits shift workers and carers.' }, { type: 'cite', n: 1 }] }); + expect(cited[1]).toEqual(blocks[1]); + expect(blocks[0]).toEqual({ type: 'paragraph', inline: [{ type: 'text', text: 'Every real ban permits shift workers and carers.' }] }); + }); +}); diff --git a/apps/mobile/src/lib/markdown.ts b/apps/mobile/src/lib/markdown.ts new file mode 100644 index 000000000..233681f6c --- /dev/null +++ b/apps/mobile/src/lib/markdown.ts @@ -0,0 +1,214 @@ +/** + * A small Markdown reader for Ask answers. The website renders them with + * react-markdown, which needs the DOM, so the app reads the subset the coach + * writes: paragraphs, headings, lists, quotes, code, bold, italic, links and + * numbered citation markers like [2]. Answers arrive token by token, so an + * unfinished **bold** run renders bold until it closes. + */ + +export type Inline = + | { type: 'text'; text: string } + | { type: 'bold'; children: Inline[] } + | { type: 'italic'; children: Inline[] } + | { type: 'code'; text: string } + | { type: 'link'; text: string; url: string } + | { type: 'cite'; n: number }; + +export type Block = + | { type: 'paragraph'; inline: Inline[] } + | { type: 'heading'; level: number; inline: Inline[] } + | { type: 'list'; ordered: boolean; start: number; items: Inline[][] } + | { type: 'quote'; inline: Inline[] } + | { type: 'code'; text: string } + | { type: 'rule' }; + +const HEADING = /^(#{1,6})\s+(.*)$/; +const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/; +const BULLET = /^\s*[-*+]\s+(.*)$/; +const ORDERED = /^\s*(\d{1,3})[.)]\s+(.*)$/; +const QUOTE = /^\s{0,3}>\s?(.*)$/; +const FENCE = /^\s{0,3}```/; + +export function parseInline(text: string): Inline[] { + const out: Inline[] = []; + let buffer = ''; + const flush = () => { + if (buffer) out.push({ type: 'text', text: buffer }); + buffer = ''; + }; + let i = 0; + while (i < text.length) { + const rest = text.slice(i); + if (rest.startsWith('`')) { + const end = text.indexOf('`', i + 1); + if (end > i + 1) { + flush(); + out.push({ type: 'code', text: text.slice(i + 1, end) }); + i = end + 1; + continue; + } + } + const strong = rest.startsWith('**') ? '**' : rest.startsWith('__') ? '__' : null; + if (strong) { + const end = text.indexOf(strong, i + 2); + flush(); + // Unclosed while streaming: bold to the end until the closing marker arrives. + const inner = end === -1 ? text.slice(i + 2) : text.slice(i + 2, end); + if (inner) out.push({ type: 'bold', children: parseInline(inner) }); + i = end === -1 ? text.length : end + 2; + continue; + } + const emphasis = rest[0] === '*' || (rest[0] === '_' && !/\w/.test(text[i - 1] ?? '')) ? rest[0] : null; + if (emphasis && rest[1] && rest[1] !== ' ') { + const end = text.indexOf(emphasis, i + 1); + if (end > i + 1 && text[end - 1] !== ' ' && (emphasis === '*' || !/\w/.test(text[end + 1] ?? ''))) { + flush(); + out.push({ type: 'italic', children: parseInline(text.slice(i + 1, end)) }); + i = end + 1; + continue; + } + } + const link = /^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/.exec(rest); + if (link) { + flush(); + out.push({ type: 'link', text: link[1], url: link[2] }); + i += link[0].length; + continue; + } + const cite = /^\[(\d{1,2})\]/.exec(rest); + if (cite) { + flush(); + out.push({ type: 'cite', n: Number(cite[1]) }); + i += cite[0].length; + continue; + } + buffer += text[i]; + i += 1; + } + flush(); + return out; +} + +export function parseMarkdown(source: string): Block[] { + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + const blocks: Block[] = []; + let paragraph: string[] = []; + let list: { ordered: boolean; start: number; items: string[] } | null = null; + let quote: string[] = []; + + const endParagraph = () => { + if (paragraph.length) blocks.push({ type: 'paragraph', inline: parseInline(paragraph.join(' ')) }); + paragraph = []; + }; + const endList = () => { + if (list) blocks.push({ type: 'list', ordered: list.ordered, start: list.start, items: list.items.map(parseInline) }); + list = null; + }; + const endQuote = () => { + if (quote.length) blocks.push({ type: 'quote', inline: parseInline(quote.join(' ')) }); + quote = []; + }; + const endAll = () => { + endParagraph(); + endList(); + endQuote(); + }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (FENCE.test(line)) { + endAll(); + const code: string[] = []; + index += 1; + while (index < lines.length && !FENCE.test(lines[index])) { + code.push(lines[index]); + index += 1; + } + blocks.push({ type: 'code', text: code.join('\n') }); + continue; + } + if (!line.trim()) { + endAll(); + continue; + } + const heading = HEADING.exec(line); + if (heading) { + endAll(); + blocks.push({ type: 'heading', level: heading[1].length, inline: parseInline(heading[2].trim()) }); + continue; + } + if (RULE.test(line)) { + endAll(); + blocks.push({ type: 'rule' }); + continue; + } + const bullet = BULLET.exec(line); + const ordered = bullet ? null : ORDERED.exec(line); + if (bullet || ordered) { + endParagraph(); + endQuote(); + const isOrdered = Boolean(ordered); + if (!list || list.ordered !== isOrdered) { + endList(); + list = { ordered: isOrdered, start: ordered ? Number(ordered[1]) : 1, items: [] }; + } + list.items.push((bullet ? bullet[1] : ordered![2]).trim()); + continue; + } + const quoted = QUOTE.exec(line); + if (quoted) { + endParagraph(); + endList(); + quote.push(quoted[1].trim()); + continue; + } + if (list && /^\s+\S/.test(line)) { + // An indented line continues the last list item. + list.items[list.items.length - 1] += ` ${line.trim()}`; + continue; + } + endList(); + endQuote(); + paragraph.push(line.trim()); + } + endAll(); + return blocks; +} + +function plainText(inline: Inline[]): string { + return inline.map((node) => + node.type === 'text' || node.type === 'code' || node.type === 'link' ? node.text + : node.type === 'bold' || node.type === 'italic' ? plainText(node.children) + : '').join(''); +} + +function normalize(text: string): string { + return text.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); +} + +function hasCite(inline: Inline[], n: number): boolean { + return inline.some((node) => (node.type === 'cite' && node.n === n) || ((node.type === 'bold' || node.type === 'italic') && hasCite(node.children, n))); +} + +/** + * Places citation n after the paragraph or list item that contains its claim, + * when the answer quotes the claim. Claims the answer only paraphrases keep + * their chip in the row under the answer. + */ +export function attachCitations(blocks: Block[], claims: string[]): Block[] { + const next = blocks.map((block) => (block.type === 'list' ? { ...block, items: block.items.map((item) => [...item]) } : 'inline' in block ? { ...block, inline: [...block.inline] } : block)); + claims.forEach((claim, index) => { + const n = index + 1; + const key = normalize(claim).slice(0, 48); + if (key.length < 16) return; + for (const block of next) { + const targets = block.type === 'list' ? block.items : 'inline' in block ? [block.inline] : []; + const target = targets.find((inline) => normalize(plainText(inline)).includes(key)); + if (target) { + if (!hasCite(target, n)) target.push({ type: 'cite', n }); + return; + } + } + }); + return next; +} diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts new file mode 100644 index 000000000..f4027fb94 --- /dev/null +++ b/apps/mobile/src/lib/sources.ts @@ -0,0 +1,152 @@ +import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/core/ai/schemas/source-import'; +import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; + +import { supabase } from './supabase'; + +/** + * Library sources, read and written the way the website's session store + * (src/lib/stores/session-store.ts: addSource, removeSource) and + * src/lib/source-import.ts do. A source belongs to one debate. + */ + +export type SourceType = 'url' | 'file' | 'text'; + +export interface Source { + id: string; + title: string; + content: string; + type: SourceType; + origin: string | null; + createdAt: number; + /** Results that used this source, from the website's source assessments. */ + useCount: number; +} + +const SOURCE_COLUMNS = 'id,title,content,type,origin,metadata,created_at'; +const DEFAULT_FOLDER = 'General'; + +interface SourceRow { + id: string; + title: string; + content: string; + type: string; + origin: string | null; + metadata: unknown; + created_at: string; +} + +function timesUsed(metadata: unknown): number { + if (!metadata || typeof metadata !== 'object') return 0; + const semantic = (metadata as { semantic?: { useCount?: unknown } }).semantic; + const count = semantic?.useCount; + return typeof count === 'number' && Number.isInteger(count) && count > 0 ? count : 0; +} + +function fromRow(row: SourceRow): Source { + return { + id: row.id, + title: row.title, + content: row.content, + type: row.type === 'url' || row.type === 'file' ? row.type : 'text', + origin: row.origin || null, + createdAt: new Date(row.created_at).getTime(), + useCount: timesUsed(row.metadata), + }; +} + +/** + * 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. + */ + +/** One debate's sources, newest first, as the website's Library lists them by default. */ +export async function fetchSources(userId: string, debateId: string): Promise { + const { data, error } = await supabase + .from('sources') + .select(SOURCE_COLUMNS) + .eq('user_id', userId) + .eq('debate_id', debateId) + .order('created_at', { ascending: false }); + if (error) throw new Error(error.message); + return ((data ?? []) as SourceRow[]).map(fromRow); +} + +export async function fetchSource(userId: string, id: string): Promise { + const { data, error } = await supabase + .from('sources') + .select(SOURCE_COLUMNS) + .eq('user_id', userId) + .eq('id', id) + .maybeSingle(); + if (error) throw new Error(error.message); + return data ? fromRow(data as SourceRow) : null; +} + +/** Saves a source to a debate in the General folder, as the website's Library does. */ +export async function addSource( + userId: string, + input: { title: string; content: string; type: SourceType; origin?: string | null; debateId: string }, +): Promise { + const folderResult = await supabase + .from('source_folders') + .upsert({ user_id: userId, name: DEFAULT_FOLDER }, { onConflict: 'user_id,name' }); + if (folderResult.error) throw new Error(folderResult.error.message); + const { data, error } = await supabase + .from('sources') + .insert({ + user_id: userId, + debate_id: input.debateId, + title: input.title, + content: input.content, + type: input.type, + origin: input.origin || null, + folder: DEFAULT_FOLDER, + is_active: true, + metadata: null, + }) + .select(SOURCE_COLUMNS) + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not save the source'); + notifySourcesChanged(); + 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); + if (error) throw new Error(error.message); + notifySourcesChanged(); +} + +/** The website rejects a URL that is already in the debate's Library. */ +export function isDuplicateOrigin(sources: Source[], origin: string): boolean { + const needle = origin.trim().toLowerCase(); + return Boolean(needle) && sources.some((source) => source.origin?.trim().toLowerCase() === needle); +} + +/** Reads a page through the import-source Edge Function, which returns an editable draft. */ +export async function importSourceFromUrl(url: string): Promise { + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + const { data, error } = await supabase.functions.invoke('import-source', { + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + body: { url }, + }); + if (error) throw new Error((await getEdgeFunctionErrorMessage(error)) || 'Failed to import source'); + const parsed = SourceImportOutputSchema.safeParse(data); + if (!parsed.success) throw new Error('Imported source response was invalid'); + return parsed.data; +} + +// Screens showing sources reload when another screen adds or removes one. +const listeners = new Set<() => void>(); + +export function subscribeSourcesChanged(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function notifySourcesChanged() { + listeners.forEach((listener) => listener()); +} diff --git a/apps/mobile/src/theme/colors.ts b/apps/mobile/src/theme/colors.ts index 4b7eca48c..883579aaa 100644 --- a/apps/mobile/src/theme/colors.ts +++ b/apps/mobile/src/theme/colors.ts @@ -16,6 +16,8 @@ export const palette = { onPrimary: '#ffffff', tile: '#e0f2fe', tileForeground: '#0284c7', + chipSelectedBorder: '#7dd3fc', + chipSelectedForeground: '#075985', segmentTrack: '#e2e8f0', segmentSelected: '#ffffff', input: '#ffffff', @@ -36,6 +38,8 @@ export const palette = { onPrimary: '#082f49', tile: '#15304a', tileForeground: '#7dd3fc', + chipSelectedBorder: '#0369a1', + chipSelectedForeground: '#bae6fd', segmentTrack: '#1e293b', segmentSelected: '#334155', input: '#0f172a', diff --git a/docs/development.md b/docs/development.md index 32c7fd3d0..a1e306018 100644 --- a/docs/development.md +++ b/docs/development.md @@ -108,7 +108,7 @@ npm run -w @debatelab/mobile export:web PORT= npx tsx apps/mobile/scripts/screenshots.ts ``` -Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs (the Debate tab in both its In favor and Against views), the debate switcher, the new-debate form and the Debate tab's empty state, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. +Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs (the Debate tab in both its In favor and Against views), the debate switcher, the new-debate form, the Debate tab's empty state, the Library's plus menu, link and note sheets and a source, and Ask empty, mid-answer and answered with citations, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. Ask runs on a fake stream inside the page and a fixed answer with citations, so screenshots never call a model or save a chat. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. ### Expo Go on Adam's iPhone diff --git a/package-lock.json b/package-lock.json index ca545ac95..3f22ef75d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,6 +136,7 @@ "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", "i18next": "^23.16.8", "react": "19.2.3", "react-dom": "19.2.3", @@ -825,6 +826,16 @@ } } }, + "apps/mobile/node_modules/expo-web-browser": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.3.tgz", + "integrity": "sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo/node_modules/@expo/cli": { "version": "57.0.28", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.28.tgz", diff --git a/packages/core/src/ai/sse.ts b/packages/core/src/ai/sse.ts new file mode 100644 index 000000000..a6aa7ffec --- /dev/null +++ b/packages/core/src/ai/sse.ts @@ -0,0 +1,40 @@ +// Server-sent events parsing for the ai-stream Edge Function, shared by the website and the app. + +export type ParsedSSEEvent = { + event: string; + data: unknown; +}; + +function parseEventBlock(block: string): ParsedSSEEvent | null { + const lines = block.split(/\r?\n/); + let event = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + event = line.slice('event:'.length).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()); + } + } + + if (dataLines.length === 0) return null; + + const rawData = dataLines.join('\n'); + try { + return { event, data: JSON.parse(rawData) }; + } catch { + return { event, data: rawData }; + } +} + +export function consumeSSEChunk(buffer: string, chunk: string): { events: ParsedSSEEvent[]; buffer: string } { + const normalized = `${buffer}${chunk}`.replace(/\r\n/g, '\n'); + const parts = normalized.split('\n\n'); + const rest = parts.pop() ?? ''; + const events = parts + .map(parseEventBlock) + .filter((event): event is ParsedSSEEvent => event !== null); + + return { events, buffer: rest }; +} diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index eaf144713..3401d63cc 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -4,6 +4,7 @@ import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, boundAs import { parseDebateContextUsage, useDebateContextStore } from '@/lib/stores/debate-context-store'; import type { ProviderConfig } from './providers/types'; import { AskOutputSchema, type AskOutput } from './schemas/ask'; +import { consumeSSEChunk, type ParsedSSEEvent } from '@debatelab/core/ai/sse'; export interface AskStreamOptions { mode?: string; @@ -15,17 +16,14 @@ export interface AskStreamOptions { debateId?: string | null; } +export { consumeSSEChunk, type ParsedSSEEvent }; + export interface AskStreamHandlers { onDelta: (text: string) => void; onFinal?: (output: AskOutput) => void; signal?: AbortSignal; } -export type ParsedSSEEvent = { - event: string; - data: unknown; -}; - export function isAskStreamingSupported(config: ProviderConfig, options: AskStreamOptions = {}) { const advancedStructuredMode = Boolean( (options.mode ?? 'general') !== 'general' || @@ -38,40 +36,6 @@ export function isAskStreamingSupported(config: ProviderConfig, options: AskStre return !advancedStructuredMode && config.provider === 'openrouter'; } -function parseEventBlock(block: string): ParsedSSEEvent | null { - const lines = block.split(/\r?\n/); - let event = 'message'; - const dataLines: string[] = []; - - for (const line of lines) { - if (line.startsWith('event:')) { - event = line.slice('event:'.length).trim(); - } else if (line.startsWith('data:')) { - dataLines.push(line.slice('data:'.length).trimStart()); - } - } - - if (dataLines.length === 0) return null; - - const rawData = dataLines.join('\n'); - try { - return { event, data: JSON.parse(rawData) }; - } catch { - return { event, data: rawData }; - } -} - -export function consumeSSEChunk(buffer: string, chunk: string): { events: ParsedSSEEvent[]; buffer: string } { - const normalized = `${buffer}${chunk}`.replace(/\r\n/g, '\n'); - const parts = normalized.split('\n\n'); - const rest = parts.pop() ?? ''; - const events = parts - .map(parseEventBlock) - .filter((event): event is ParsedSSEEvent => event !== null); - - return { events, buffer: rest }; -} - function abortError(): DOMException { return new DOMException('Aborted', 'AbortError'); } diff --git a/src/locales/en.json b/src/locales/en.json index 2ce586d23..890e92661 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2446,6 +2446,50 @@ }, "deleteAccount": { "failedHint": "Your account is still here. Check your connection and try again." + }, + "library": { + "add": "Add a source", + "pasteLink": "Paste a link", + "writeNote": "Write a note", + "search_one": "Search {{count}} source", + "search_other": "Search {{count}} sources", + "filters": { + "all": "All", + "files": "Files", + "links": "Links", + "notes": "Notes" + }, + "types": { + "url": "Link", + "text": "Note", + "file": "File" + }, + "metaAdded": "{{type}}, added {{when}}", + "metaCited_one": "{{type}}, cited in {{count}} result", + "metaCited_other": "{{type}}, cited in {{count}} results", + "loadFailed": "Could not load your sources.", + "deleteFailed": "Could not delete the source. Try again.", + "saveFailed": "Could not save the source. Try again.", + "sourceMissing": "This source is no longer in your Library.", + "openPage": "Opens the page in the browser" + }, + "ask": { + "placeholder": "Ask your coach", + "openYou": "See your plan", + "openFailed": "Could not open this chat. Try again.", + "historyFailed": "Could not load your chats.", + "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 Ask 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 request took too long. Please try again.", + "tooLarge": "Your question is too long. Shorten it and send it again.", + "debateMissing": "This debate no longer exists. Pick another debate and ask again.", + "failed": "The coach could not answer. Try again." + } } } } diff --git a/src/locales/es.json b/src/locales/es.json index a8d2c96d6..dc4120f84 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2446,6 +2446,50 @@ }, "deleteAccount": { "failedHint": "Tu cuenta sigue activa. Comprueba tu conexión e inténtalo de nuevo." + }, + "library": { + "add": "Añadir una fuente", + "pasteLink": "Pegar un enlace", + "writeNote": "Escribir una nota", + "search_one": "Buscar en {{count}} fuente", + "search_other": "Buscar en {{count}} fuentes", + "filters": { + "all": "Todas", + "files": "Archivos", + "links": "Enlaces", + "notes": "Notas" + }, + "types": { + "url": "Enlace", + "text": "Nota", + "file": "Archivo" + }, + "metaAdded": "{{type}}, se añadió {{when}}", + "metaCited_one": "{{type}}, aparece en {{count}} resultado", + "metaCited_other": "{{type}}, aparece en {{count}} resultados", + "loadFailed": "No se pudieron cargar tus fuentes.", + "deleteFailed": "No se pudo eliminar la fuente. Inténtalo de nuevo.", + "saveFailed": "No se pudo guardar la fuente. Inténtalo de nuevo.", + "sourceMissing": "Esta fuente ya no está en tu biblioteca.", + "openPage": "Abre la página en el navegador" + }, + "ask": { + "placeholder": "Pregunta a tu coach", + "openYou": "Ver tu plan", + "openFailed": "No se pudo abrir este chat. Inténtalo de nuevo.", + "historyFailed": "No se pudieron cargar tus chats.", + "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 Preguntar 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 solicitud tardó demasiado. Inténtalo de nuevo.", + "tooLarge": "Tu pregunta es demasiado larga. Acórtala y envíala de nuevo.", + "debateMissing": "Este debate ya no existe. Elige otro debate y vuelve a preguntar.", + "failed": "El coach no pudo responder. Inténtalo de nuevo." + } } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index d17391164..f10f693ed 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2446,6 +2446,50 @@ }, "deleteAccount": { "failedHint": "A fiókod megmaradt. Ellenőrizd a kapcsolatot, és próbáld újra." + }, + "library": { + "add": "Forrás hozzáadása", + "pasteLink": "Link beillesztése", + "writeNote": "Jegyzet írása", + "search_one": "Keresés {{count}} forrás között", + "search_other": "Keresés {{count}} forrás között", + "filters": { + "all": "Mind", + "files": "Fájlok", + "links": "Linkek", + "notes": "Jegyzetek" + }, + "types": { + "url": "Link", + "text": "Jegyzet", + "file": "Fájl" + }, + "metaAdded": "{{type}}, hozzáadva: {{when}}", + "metaCited_one": "{{type}}, {{count}} eredményben hivatkozva", + "metaCited_other": "{{type}}, {{count}} eredményben hivatkozva", + "loadFailed": "Nem sikerült betölteni a forrásaidat.", + "deleteFailed": "Nem sikerült törölni a forrást. Próbáld újra.", + "saveFailed": "Nem sikerült menteni a forrást. Próbáld újra.", + "sourceMissing": "Ez a forrás már nincs a könyvtáradban.", + "openPage": "Megnyitja az oldalt a böngészőben" + }, + "ask": { + "placeholder": "Kérdezd az edződet", + "openYou": "Előfizetés megtekintése", + "openFailed": "Nem sikerült megnyitni ezt a beszélgetést. Próbáld újra.", + "historyFailed": "Nem sikerült betölteni a beszélgetéseidet.", + "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": "A Kérdezéshez 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": "A kérés túl sokáig tartott. Próbáld újra.", + "tooLarge": "Túl hosszú a kérdésed. Rövidítsd le, és küldd el újra.", + "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." + } } } }