diff --git a/docs/api/rest.md b/docs/api/rest.md index 1a4bfc4..695cc32 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -25,6 +25,7 @@ Chat upload limits: - Response: `{ "authenticated": true, "mode": "open" | "token" }` - In token mode, a missing bearer token returns `401` (`missing bearer token`); an incorrect token returns `403` (`invalid bearer token`). - Web clients show a neutral sign-in form when the initial check has no token, and an error when a supplied token is rejected. Open development servers authenticate automatically; the sign-in form has no tokenless bypass. +- The full and `/lite` web interfaces share this authentication flow and the same stored token. If browser storage is unavailable, the token remains usable in memory for the current page but cannot be restored after reload. This does not change bearer-token requirements for API requests. ## Models diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index 54f8078..c865c08 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -17,6 +17,8 @@ Authentication: - Same as REST endpoints (`Authorization: Bearer ` when token mode is enabled) - The web client checks [`GET /v1/auth/session`](rest.md#get-v1authsession) before opening streams. An initial missing-token response prompts sign-in without an error; an incorrect supplied token is an authentication failure. The web sign-in form has no tokenless bypass; open development servers still authenticate automatically. Stream authorization requirements are unchanged. +Web presentation note: `/lite` uses these same chat streams and existing search streams, including idempotent `clientRequestId` retries and active-run attachment. It displays completed responses instead of repainting individual deltas. Its Agent view shares the room protocol and durable delivery/read-state handling. These are presentation choices; request shapes, event payloads, persistence, and endpoint semantics are unchanged. + ## Chat Completion Request Body ```json diff --git a/web/README.md b/web/README.md index d37c10f..7b1d4b3 100644 --- a/web/README.md +++ b/web/README.md @@ -29,7 +29,7 @@ Default dev URL: `http://localhost:5173` - On startup, the app checks `GET /v1/auth/session`. - If backend runs with `ADMIN_TOKEN`, sign in using that token. - If backend runs in open mode (`ADMIN_TOKEN` unset), you can continue without a token. -- The entered token is stored in browser `localStorage` and can be cleared via Logout. +- The entered token is stored in browser `localStorage` and can be cleared via Logout. When browser storage is blocked, authentication still works for the current page; sign in again after reloading. ## UI @@ -60,8 +60,48 @@ Client API contract docs: ## Routes - `/`: full Sybil app (sidebar + chat/search workspace) +- `/lite`: lightweight chat, web search, and Agent workspace for e-readers and older smartphones - `/search?q=bitcoin+price`: standalone search page that renders only the search query box and results panel +## Lightweight interface + +Open `/lite` directly, or choose **Lightweight view** in the full workspace sidebar. **Full interface** switches back. Explicit switches (`?ui=lite` and `?ui=full`) remember the choice in `sybil:ui-mode`; workspace IDs, query parameters, and hashes survive switching. A direct `/lite` visit always works even if Full was previously selected. + +At `/`, an explicit or saved choice wins over automatic detection. Otherwise recognizable Kindle/Kobo/PocketBook user agents or `navigator.connection.saveData === true` select Lite. Silk/Kindle Fire alone do not identify an e-ink device. Screen size, CPU count, and reduced-motion preferences do not trigger a redirect. `/search` stays a standalone search unless explicitly switched; switching its `q` parameter into Lite prefills a search without starting a request. + +Detection is intentionally limited: [user agent identification is unreliable](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Browser_detection_using_the_user_agent), and [Save-Data](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/saveData) is an explicit user preference available only in some browsers. Readers without recognizable browser data can bookmark `/lite`. + +Lite follows the existing workspace layout: sidebar, compact conversation header, scrolling transcript, and bottom composer. It uses the same Inter font stack and SYBIL wordmark, the original chat bubble shapes, and the shared assistant metadata/Copy footer. Solid, high-contrast light surfaces replace gradients and translucent backgrounds; there are no character images, animations, or added welcome/tagline copy. Mobile uses an instant sidebar toggle, and pinch zoom remains available. + +The route does not load the full workspace stylesheet or animated Agent presentation. Typography, bubble geometry, and assistant reply actions are shared with the full interface. Markdown tables and code scroll within the transcript; history and ordinary transcripts render in batches of 30. Images are not rendered automatically. Regular chats and searches consume the existing SSE APIs but publish completed responses, avoiding repeated token-by-token screen redraws. Refresh restores saved content and reconnects to selected active runs. Chat retries retain the same submission ID. + +The Agent view loads only when opened and reuses the shared room hook, durable outbox, older history, and read tracking. It receives completed message updates, supports failed-delivery retries, and downloads attachments only when requested. Lite does not start notification presence polling or register a service worker. Existing browser workers/subscriptions are unaffected by switching views; sign-out keeps the shared revocation behavior. + +The focused controls support reading and sending text, model selection (including custom model names), saved-history search, web searches, and Agent conversations. Uploads, chat settings, fork management, Quick Questions, and notification settings remain accessible through **Full interface**. Existing chat settings and attachments remain part of the shared conversation context. + +### Code boundaries + +- `main.tsx`, `root-router.tsx`, and `lib/ui-mode.ts` choose and lazily load one presentation before its UI assets download. Mode links perform document navigation to keep global styles isolated. +- `pages/lite-route-page.tsx` and `pages/lite.css` own Lite's markup and appearance. Full UI components do not need Lite conditionals. +- `typography.css`, `components/chat/message-bubble.css`, and `AssistantMessageFooter` keep fonts, bubble geometry, and reply actions consistent across the two presentations. Lite supplies flat color values through CSS variables. +- `hooks/use-lite-workspace.ts` adapts workspace state to completion-only rendering. Both presentations use `lib/api.ts`, `lib/workspace-domain.ts` (model preferences, history payloads, attachment context, URLs, and retry rules), `useSessionAuth`, `useWorkspaceSearch`, and sanitized `MarkdownContent`. +- `components/lite/lite-agent.tsx` supplies a simple presentation over `useAgentRoom`; transport, delivery identities, attachments, and read-state semantics stay shared. + +Production syntax targets Chrome 87, Firefox 78, and Safari 14. Browsers still need JavaScript modules, dynamic imports, Fetch/ReadableStream, AbortController, and BigInt; Agent additionally uses WebSocket and browser storage. A small feature-detected shim supplies `Array.at`, used by the Markdown dependency, and secure UUID creation falls back to `crypto.getRandomValues`. This is not an ES5 frontend or a guarantee for every Kindle firmware. Browsers without module support show an explanatory fallback. Physical Kindle testing remains necessary. + +### Verification + +Run `npm test` and `npm run build`, then `npm run preview -- --host 127.0.0.1 --port 5197`. Browser fixtures mock all API calls and require Playwright Chromium (set `PLAYWRIGHT_MODULE` / `CHROME_EXECUTABLE` if installed elsewhere): + +```bash +node tests/ui-mode.browser.mjs +node tests/lite-workspace.browser.mjs +node tests/lite-workspace-recovery.browser.mjs +node tests/lite-agent.browser.mjs +``` + +Set `LITE_ARTIFACT_DIR=/tmp/sybil-lite-qa` on the workspace fixture to save desktop and 320/390/600 px screenshots. Checks cover routing overrides, blocked storage, asset isolation, shared typography and bubble shapes, absence of added flavor text, composer placement, Markdown without native `Array.at`, chat submission/retry, search, saved-history navigation, active-run recovery, Agent delivery, and narrow-screen overflow. Chromium emulation does not reproduce e-ink refresh behavior or older engine implementations. + ## Mobile web Installed Sybil uses `standalone` display, retaining the system status bar where the browser supports it. The viewport keeps pinch zoom disabled. Compact regular-chat replies use the transcript width and the active provider/model appears beneath the title; Agent keeps its existing layout. diff --git a/web/index.html b/web/index.html index 7c00214..73e72d0 100644 --- a/web/index.html +++ b/web/index.html @@ -20,6 +20,10 @@
+ + diff --git a/web/src/App.tsx b/web/src/App.tsx index fa77b56..f491832 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,3 +1,12 @@ +import { + readSidebarSelectionFromUrl, buildWorkspaceUrl, + PROVIDER_FALLBACK_MODELS, EMPTY_MODEL_CATALOG, EMPTY_MODEL_PREFERENCES, + createClientRequestId, isRecoverableStreamDisconnect, hasNewPersistedUserMessage, + getModelOptions, getVisibleProviders, loadStoredModelPreferences, saveModelPreferences, + normalizeStoredProvider, normalizeStoredModelPreferences, pickProviderModel, buildCompletionMessages, + type ProviderModelPreferences, +} from "@/lib/workspace-domain"; +import { buildUiModeUrl } from "@/lib/ui-mode"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import type { TargetedTouchEvent } from "preact"; import { getProviderLabel } from "@/lib/message-generation"; @@ -64,7 +73,6 @@ import { type ChatDetail, type ChatSummary, type ChatToolInfo, - type CompletionRequestMessage, type Message, type SearchDetail, type SearchSummary, @@ -139,53 +147,6 @@ type RefreshCollectionsOptions = { selectFallback?: boolean; }; -function readSidebarSelectionFromUrl(): SidebarSelection | null { - if (typeof window === "undefined") return null; - const params = new URLSearchParams(window.location.search); - if (params.get("agent") === "root") return { kind: "agent", id: "root" }; - const chatId = params.get("chat")?.trim(); - if (chatId) { - return { kind: "chat", id: chatId }; - } - const searchId = params.get("search")?.trim(); - if (searchId) { - return { kind: "search", id: searchId }; - } - return null; -} - -function buildWorkspaceUrl(selection: SidebarSelection | null) { - if (typeof window === "undefined") return "/"; - const params = new URLSearchParams(window.location.search); - params.delete("chat"); - params.delete("search"); - params.delete("agent"); - if (selection) { - params.set(selection.kind, selection.id); - } - const query = params.toString(); - return `${window.location.pathname}${query ? `?${query}` : ""}`; -} - -const PROVIDER_FALLBACK_MODELS: Record = { - openai: ["gpt-4.1-mini", QUICK_QUESTION_DEFAULT_MODEL], - anthropic: ["claude-3-5-sonnet-latest"], - xai: ["grok-3-mini"], - gemini: ["gemini-3.5-flash", "gemini-flash-latest"], - "hermes-agent": ["hermes-agent"], -}; - -const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { - openai: { models: [], loadedAt: null, error: null }, - anthropic: { models: [], loadedAt: null, error: null }, - xai: { models: [], loadedAt: null, error: null }, - gemini: { models: [], loadedAt: null, error: null }, -}; - -const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"]; -const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"]; - -const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider"; const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection"; const STREAM_RESUME_RETRY_DELAYS_MS = [0, 250, 750, 1500]; const MOBILE_LAYOUT_MEDIA_QUERY = "(max-width: 767px)"; @@ -205,20 +166,11 @@ type MobileWorkspaceSwipe = { direction: -1 | 1 | null; }; -type ProviderModelPreferences = Record; - type QuickQuestionModelSelection = { provider: Provider; modelPreferences: ProviderModelPreferences; }; -const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = { - openai: null, - anthropic: null, - xai: null, - gemini: null, - "hermes-agent": null, -}; const EMPTY_ACTIVE_RUNS: ActiveRunsState = { chats: {}, searches: {}, @@ -285,34 +237,6 @@ function getActiveRunsAfterResume() { return retryAfterAppResume(getActiveRuns); } -function createClientRequestId() { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - return `web-${Date.now()}-${Math.random().toString(36).slice(2)}`; -} - -function isRecoverableStreamDisconnect(error: unknown) { - if (error instanceof TypeError) return true; - const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); - return ( - message.includes("failed to fetch") || - message.includes("load failed") || - message.includes("network error") || - message.includes("networkerror") || - message.includes("no response stream") || - message.includes("stream disconnected before completion") - ); -} - -function hasNewPersistedUserMessage(chat: ChatDetail, previousMessageIds: Set, content: string, attachments: ChatAttachment[]) { - return chat.messages.some((message) => { - if (previousMessageIds.has(message.id) || message.role !== "user" || message.content !== content) return false; - const persistedAttachmentIds = new Set(getMessageAttachments(message.metadata).map((attachment) => attachment.id)); - return attachments.length === persistedAttachmentIds.size && attachments.every((attachment) => persistedAttachmentIds.has(attachment.id)); - }); -} - const TRANSCRIPT_BOTTOM_GAP = 20; const REPLY_SCROLL_BUFFER_MIN = 288; const REPLY_SCROLL_BUFFER_MAX = 576; @@ -377,16 +301,6 @@ const TEXT_ATTACHMENT_MIME_TYPES = new Set([ "image/svg+xml", ]); -function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) { - const providerModels = catalog[provider]?.models ?? []; - if (providerModels.length) return providerModels; - return PROVIDER_FALLBACK_MODELS[provider]; -} - -function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) { - return ALL_PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined); -} - function getReplyScrollBufferHeight() { if (typeof window === "undefined") return REPLY_SCROLL_BUFFER_MIN; return Math.min( @@ -492,44 +406,6 @@ function hasFileTransfer(dataTransfer: DataTransfer | null) { return Array.from(dataTransfer.types ?? []).includes("Files") || getFilesFromDataTransfer(dataTransfer).length > 0; } -function loadStoredModelPreferences() { - if (typeof window === "undefined") return EMPTY_MODEL_PREFERENCES; - try { - const raw = window.localStorage.getItem(MODEL_PREFERENCES_STORAGE_KEY); - if (!raw) return EMPTY_MODEL_PREFERENCES; - const parsed = JSON.parse(raw) as Partial>; - return { - openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null, - anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null, - xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null, - gemini: typeof parsed.gemini === "string" && parsed.gemini.trim() ? parsed.gemini.trim() : null, - "hermes-agent": - typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null, - }; - } catch { - return EMPTY_MODEL_PREFERENCES; - } -} - -function normalizeStoredProvider(value: unknown): Provider { - return value === "anthropic" || value === "xai" || value === "gemini" || value === "openai" || value === "hermes-agent" - ? value - : "openai"; -} - -function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences { - if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES; - const parsed = value as Partial>; - return { - openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null, - anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null, - xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null, - gemini: typeof parsed.gemini === "string" && parsed.gemini.trim() ? parsed.gemini.trim() : null, - "hermes-agent": - typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null, - }; -} - function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection { if (typeof window === "undefined") { return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES }; @@ -547,11 +423,6 @@ function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection { } } -function pickProviderModel(options: string[], preferred: string | null) { - if (preferred?.trim()) return preferred.trim(); - return options[0] ?? ""; -} - function getToolLabel(name: string) { if (name === "web_search") return "Web search"; if (name === "fetch_url") return "Fetch URL"; @@ -597,10 +468,6 @@ function asToolLogMetadata(value: unknown): ToolLogMetadata | null { return record as ToolLogMetadata; } -function isToolCallLogMessage(message: Message) { - return asToolLogMetadata(message.metadata) !== null; -} - function isDisplayableMessage(message: Message) { return message.role !== "system"; } @@ -1422,7 +1289,7 @@ export default function App() { hasSyncedSelectionHistoryRef.current = false; return; } - const current = `${window.location.pathname}${window.location.search}`; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; const next = buildWorkspaceUrl(selectedItem); if (!hasSyncedSelectionHistoryRef.current) { @@ -1455,7 +1322,7 @@ export default function App() { useEffect(() => { if (typeof window === "undefined") return; - window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences)); + saveModelPreferences(providerModelPreferences); }, [providerModelPreferences]); useEffect(() => { @@ -2600,22 +2467,7 @@ export default function App() { })); addActiveRun("chat", chatId); - const requestMessages: CompletionRequestMessage[] = [ - ...baseChat.messages - .filter((message) => !isToolCallLogMessage(message)) - .map((message) => ({ - id: message.id, - role: message.role, - content: message.content, - ...(message.name ? { name: message.name } : {}), - ...(getMessageAttachments(message.metadata).length ? { attachments: getMessageAttachments(message.metadata) } : {}), - })), - { - role: "user", - content, - ...(attachments.length ? { attachments } : {}), - }, - ]; + const requestMessages = buildCompletionMessages(baseChat.messages, content, attachments); if (shouldRequestChatTitle(baseChat, pendingTitleGenerationRef.current.has(chatId)) && !content.toLowerCase().includes("@sybil")) { pendingTitleGenerationRef.current.add(chatId); @@ -3712,6 +3564,9 @@ export default function App() {

Showing the 100 most recent matches. Refine your search to find more.

) : null} + + Lightweight view + {authMode === "token" ? : null} diff --git a/web/src/components/chat/assistant-message-footer.css b/web/src/components/chat/assistant-message-footer.css new file mode 100644 index 0000000..fe57f75 --- /dev/null +++ b/web/src/components/chat/assistant-message-footer.css @@ -0,0 +1,76 @@ +.assistant-message-footer { + position: relative; + color: var(--message-footer-color, hsl(var(--muted-foreground))); +} + +.assistant-message-footer__metadata, +.assistant-message-footer__status { + font-size: .75rem; + line-height: 1.25rem; + overflow-wrap: anywhere; +} + +.assistant-message-footer__metadata time { + white-space: nowrap; +} + +.assistant-message-footer__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .25rem; + margin-left: -.5rem; +} + +.assistant-message-footer__action { + display: inline-flex; + height: 2.5rem; + min-height: 2.5rem; + align-items: center; + gap: .375rem; + border: 0; + border-radius: var(--radius, 0); + padding: 0 .5rem; + font: inherit; + font-size: .75rem; + line-height: 1rem; + color: inherit; + background: none; + cursor: pointer; + transition: var(--message-footer-transition, color 150ms cubic-bezier(.4, 0, .2, 1), background-color 150ms cubic-bezier(.4, 0, .2, 1)); +} + +.assistant-message-footer__action svg { + width: .875rem; + height: .875rem; +} + +.assistant-message-footer__action:hover { + background: var(--message-footer-hover-background, rgb(167 139 250 / .12)); + color: var(--message-footer-hover-color, #ede9fe); +} + +.assistant-message-footer__action:focus-visible { + outline: 2px solid var(--message-footer-focus-color, hsl(var(--ring))); +} + +.assistant-message-footer__action:disabled { + cursor: wait; + opacity: .5; +} + +.assistant-message-footer__status { + display: block; +} + +.assistant-message-footer__status--hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/web/src/components/chat/assistant-message-footer.tsx b/web/src/components/chat/assistant-message-footer.tsx index bc20c31..b321441 100644 --- a/web/src/components/chat/assistant-message-footer.tsx +++ b/web/src/components/chat/assistant-message-footer.tsx @@ -2,11 +2,12 @@ import { useEffect, useRef, useState } from "preact/hooks"; import { Check, Copy, GitFork } from "lucide-preact"; import type { Message } from "@/lib/api"; import { getMessageGenerationLabel } from "@/lib/message-generation"; +import "./assistant-message-footer.css"; const receivedFormat = new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); -const actionClass = "inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-xs transition-colors hover:bg-violet-400/12 hover:text-violet-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring disabled:cursor-wait disabled:opacity-50"; +const actionClass = "assistant-message-footer__action"; export function AssistantMessageFooter({ message, onFork, isForking = false }: { message: Message; @@ -32,24 +33,24 @@ export function AssistantMessageFooter({ message, onFork, isForking = false }: { const generation = getMessageGenerationLabel(message); return ( -
-
- {hasDate ? <>