feat(web): add lightweight workspace matching existing UI
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ Authentication:
|
||||
- Same as REST endpoints (`Authorization: Bearer <token>` 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
|
||||
|
||||
+41
-1
@@ -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.
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<noscript>Sybil needs JavaScript. Enable it in your browser to use the lightweight UI at /lite.</noscript>
|
||||
<script nomodule>
|
||||
document.getElementById("app").textContent = "This browser cannot run Sybil. A browser with JavaScript module support is required, including for the lightweight UI.";
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+15
-160
@@ -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<Provider, string[]> = {
|
||||
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<Provider, string | null>;
|
||||
|
||||
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<string>, 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<Record<Provider, string>>;
|
||||
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<Record<Provider, unknown>>;
|
||||
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() {
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<a href={buildUiModeUrl("lite", new URL(buildWorkspaceUrl(selectedItem), window.location.href))} className="mx-4 mb-3 text-xs text-violet-100/70 underline underline-offset-4">
|
||||
Lightweight view
|
||||
</a>
|
||||
{authMode === "token" ? <AgentNotifications onLogout={logout} /> : null}
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="text-muted-foreground" data-assistant-footer={message.id} data-mobile-swipe-ignore>
|
||||
<div className="text-xs leading-5 [overflow-wrap:anywhere]">
|
||||
{hasDate ? <><time className="whitespace-nowrap" dateTime={message.createdAt} title={`Received ${received.toLocaleString()}`}>
|
||||
<div className="assistant-message-footer" data-assistant-footer={message.id} data-mobile-swipe-ignore>
|
||||
<div className="assistant-message-footer__metadata">
|
||||
{hasDate ? <><time dateTime={message.createdAt} title={`Received ${received.toLocaleString()}`}>
|
||||
{receivedFormat.format(received)}
|
||||
</time><span aria-hidden="true"> · </span></> : null}
|
||||
<span data-message-generation>{generation ?? "Model not recorded"}</span>
|
||||
</div>
|
||||
<div className="-ml-2 flex flex-wrap items-center gap-1" role="group" aria-label="Message actions">
|
||||
<div className="assistant-message-footer__actions" role="group" aria-label="Message actions">
|
||||
{message.content.trim() ? <button type="button" className={actionClass} onClick={() => void copy()} title="Copy message" aria-label="Copy message">
|
||||
{copyState === "copied" ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : <Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{copyState === "copied" ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
|
||||
<span>{copyState === "copied" ? "Copied" : "Copy"}</span>
|
||||
</button> : null}
|
||||
{onFork ? <button type="button" className={actionClass} onClick={() => onFork(message.id)} disabled={isForking} title="Fork chat from this message" aria-label="Fork chat from this message">
|
||||
<GitFork className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<GitFork aria-hidden="true" />
|
||||
<span>Fork</span>
|
||||
</button> : null}
|
||||
</div>
|
||||
<span className={copyState === "failed" ? "block text-xs leading-5" : "sr-only"} role="status" aria-live="polite">
|
||||
<span className={`assistant-message-footer__status${copyState === "failed" ? "" : " assistant-message-footer__status--hidden"}`} role="status" aria-live="polite">
|
||||
{copyState === "copied" ? "Message copied." : copyState === "failed" ? "Could not copy. Select the message text and use your browser’s Copy command." : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/* Geometry is shared across regular chat, Agent, and the lightweight view. */
|
||||
.message-bubble {
|
||||
min-width: 0;
|
||||
padding: 13px 17px;
|
||||
border: 1px solid var(--message-bubble-border, hsl(256 37% 57% / .18));
|
||||
border-radius: 18px 18px 18px 5px;
|
||||
background: var(--message-bubble-background, linear-gradient(150deg, hsl(251 36% 15% / .93), hsl(244 35% 11% / .93)));
|
||||
box-shadow: var(--message-bubble-shadow, 0 3px 12px hsl(239 55% 2% / .13));
|
||||
font-size: 14px;
|
||||
line-height: 1.68;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-bubble--user {
|
||||
border-color: var(--message-bubble-user-border, hsl(266 80% 72% / .3));
|
||||
border-radius: 18px 18px 5px 18px;
|
||||
background: var(--message-bubble-user-background, linear-gradient(135deg, hsl(264 59% 33% / .86), hsl(259 58% 26% / .9)));
|
||||
color: var(--message-bubble-user-color, hsl(265 55% 97%));
|
||||
}
|
||||
|
||||
.message-bubble .md-content {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.message-bubble {
|
||||
padding: 11px 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { SendHorizontal } from "lucide-preact";
|
||||
import { useAgentRoom, type AgentRoomHookResult } from "@/hooks/use-agent-room";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { agentTranscriptMessages } from "@/lib/agent-room-presentation";
|
||||
import { agentAttachments, attachmentSize, type AgentAttachment } from "@/lib/agent-attachments";
|
||||
|
||||
const DRAFT_KEY = "sybil.pi-root.draft";
|
||||
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
|
||||
|
||||
function readDraft() {
|
||||
try { return sessionStorage.getItem(DRAFT_KEY) || ""; } catch { return ""; }
|
||||
}
|
||||
|
||||
function saveDraft(value: string) {
|
||||
try {
|
||||
if (value) sessionStorage.setItem(DRAFT_KEY, value);
|
||||
else sessionStorage.removeItem(DRAFT_KEY);
|
||||
} catch { /* Draft storage is optional; the shared outbox handles durable delivery. */ }
|
||||
}
|
||||
|
||||
function LiteAgentAttachment({ file, download }: {
|
||||
file: AgentAttachment;
|
||||
download: AgentRoomHookResult["downloadAttachment"];
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const request = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => request.current?.abort(), []);
|
||||
|
||||
const retrieve = async () => {
|
||||
if (request.current) return;
|
||||
const controller = new AbortController();
|
||||
request.current = controller;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await download(file, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = file.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (failure) {
|
||||
if (!controller.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed.");
|
||||
} finally {
|
||||
request.current = null;
|
||||
if (!controller.signal.aborted) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <li>
|
||||
<span>{file.filename} ({attachmentSize(file.sizeBytes)})</span>{" "}
|
||||
<button type="button" disabled={busy} onClick={() => void retrieve()} aria-label={`Download ${file.filename}`}>
|
||||
{busy ? "Downloading…" : "Download"}
|
||||
</button>
|
||||
{error ? <p className="lite-error" role="alert">{error}</p> : null}
|
||||
</li>;
|
||||
}
|
||||
|
||||
/** Mounted only while Agent is selected; protocol, outbox and read state stay shared. */
|
||||
export function LiteAgent() {
|
||||
const room = useAgentRoom(true);
|
||||
const [draft, setDraft] = useState(readDraft);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const sendingRef = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const draftRef = useRef(draft);
|
||||
const end = useRef<HTMLDivElement>(null);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const viewport = useRef({ pinned: true, top: 0, height: 0, firstId: null as string | null });
|
||||
const messages = useMemo(() => agentTranscriptMessages(room.messages), [room.messages]);
|
||||
const uploads = room.uploadQueue.snapshot();
|
||||
const pendingUploads = uploads.some(file => file.status !== "ready");
|
||||
|
||||
useEffect(() => () => { mounted.current = false; }, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = scroller.current;
|
||||
if (!element || room.isLoading) return;
|
||||
const previous = viewport.current;
|
||||
const firstId = messages[0]?.id ?? null;
|
||||
if (previous.pinned) element.scrollTop = element.scrollHeight;
|
||||
else if (previous.firstId && firstId !== previous.firstId && messages.some(message => message.id === previous.firstId)) {
|
||||
// Keep the existing messages in place when an older page is prepended.
|
||||
element.scrollTop = previous.top + element.scrollHeight - previous.height;
|
||||
}
|
||||
previous.top = element.scrollTop;
|
||||
previous.height = element.scrollHeight;
|
||||
previous.firstId = firstId;
|
||||
}, [messages, room.isLoading, room.isTyping]);
|
||||
|
||||
useEffect(() => {
|
||||
const markVisibleRead = () => {
|
||||
if (room.isLoading || document.visibilityState !== "visible" || !document.hasFocus()) return;
|
||||
const position = end.current?.getBoundingClientRect();
|
||||
const viewport = scroller.current?.getBoundingClientRect();
|
||||
if (position && viewport && viewport.width > 0 && viewport.height > 0 && position.top >= Math.max(0, viewport.top) && position.top <= Math.min(window.innerHeight, viewport.bottom)) room.markRead();
|
||||
};
|
||||
const scrollElement = scroller.current;
|
||||
markVisibleRead();
|
||||
scrollElement?.addEventListener("scroll", markVisibleRead, { passive: true });
|
||||
window.addEventListener("focus", markVisibleRead);
|
||||
window.addEventListener("resize", markVisibleRead);
|
||||
document.addEventListener("visibilitychange", markVisibleRead);
|
||||
return () => {
|
||||
scrollElement?.removeEventListener("scroll", markVisibleRead);
|
||||
window.removeEventListener("focus", markVisibleRead);
|
||||
window.removeEventListener("resize", markVisibleRead);
|
||||
document.removeEventListener("visibilitychange", markVisibleRead);
|
||||
};
|
||||
}, [room.markRead, room.isLoading, messages]);
|
||||
|
||||
const updateDraft = (value: string) => {
|
||||
draftRef.current = value;
|
||||
setDraft(value);
|
||||
saveDraft(value);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const content = draftRef.current.trim();
|
||||
if (sendingRef.current || (!content && !uploads.length) || pendingUploads || room.isLoading) return;
|
||||
sendingRef.current = true;
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
try {
|
||||
const attachments = room.uploadQueue.ready();
|
||||
await room.sendMessage(content, attachments, () => {
|
||||
// A saved outbox row owns retries from here, even if the HTTP response is lost.
|
||||
saveDraft("");
|
||||
draftRef.current = "";
|
||||
if (mounted.current) setDraft("");
|
||||
});
|
||||
} catch (failure) {
|
||||
if (mounted.current) setSendError(failure instanceof Error ? failure.message : "Message could not be sent.");
|
||||
} finally {
|
||||
sendingRef.current = false;
|
||||
if (mounted.current) setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <section className="lite-agent" aria-label="Sybil Agent">
|
||||
<div className="lite-agent-connection" aria-label="Agent connection">
|
||||
<span className="lite-muted" role="status">
|
||||
{room.isLoading ? "Loading conversation…" : room.connection === "connected" ? "Connected" : room.connection === "connecting" ? "Connecting…" : room.connection === "reconnecting" ? "Reconnecting…" : "Offline"}
|
||||
</span>
|
||||
<a href="#lite-agent-end">Latest messages</a>
|
||||
<button type="button" onClick={room.retry} disabled={room.isLoading}>Reconnect</button>
|
||||
</div>
|
||||
<div className="lite-agent-scroll" ref={scroller} onScroll={event => {
|
||||
const element = event.currentTarget;
|
||||
viewport.current.pinned = element.scrollHeight - element.scrollTop - element.clientHeight <= 24;
|
||||
viewport.current.top = element.scrollTop;
|
||||
viewport.current.height = element.scrollHeight;
|
||||
}}>
|
||||
<div className="lite-transcript" aria-label="Agent messages" aria-busy={room.isLoading}>
|
||||
{room.error ? <p className="lite-error" role="alert">{room.error}</p> : null}
|
||||
{room.hasOlder ? <button type="button" onClick={room.loadOlder} disabled={room.isLoadingOlder}>
|
||||
{room.isLoadingOlder ? "Loading older messages…" : "Load older messages"}
|
||||
</button> : null}
|
||||
{room.olderError ? <p className="lite-error" role="alert">{room.olderError}</p> : null}
|
||||
{messages.map(message => {
|
||||
const info = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
||||
? message.metadata as Record<string, unknown> : {};
|
||||
const status = typeof info.status === "string" ? info.status : "complete";
|
||||
const own = message.role === "user";
|
||||
const failed = own && (status === "failed" || status === "unconfirmed");
|
||||
const files = agentAttachments(message.metadata, message.role);
|
||||
const date = new Date(message.createdAt);
|
||||
return <article key={message.id} className={`lite-message${own ? " lite-message-user" : ""}`} data-agent-message-id={message.id} aria-label={own ? "Your message" : "Sybil's message"}>
|
||||
<div className="lite-message-content">
|
||||
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
||||
{own ? <p className="lite-plain-text">{message.content}</p>
|
||||
: <MarkdownContent markdown={message.content} allowImages={false} />}
|
||||
{files.length ? <ul className="lite-attachments">{files.map(file => <LiteAgentAttachment key={file.id} file={file} download={room.downloadAttachment} />)}</ul> : null}
|
||||
</div>
|
||||
<div className="lite-message-meta">
|
||||
<span>{own ? "You" : "Sybil"}</span>
|
||||
{Number.isFinite(date.getTime()) && date.getFullYear() >= 2000 ? <time dateTime={message.createdAt} title={date.toLocaleString()}>{timeFormat.format(date)}</time> : null}
|
||||
{own && status === "sending" ? <span role="status">Sending…</span> : null}
|
||||
{own && status === "queued" ? <span role="status">Queued</span> : null}
|
||||
{failed ? <span className="lite-delivery-error" role="status">
|
||||
{status === "unconfirmed" ? "Unconfirmed" : "Not sent"}{" "}
|
||||
<button type="button" aria-label="Retry message" onClick={() => void room.retryMessage(message.id).catch(() => undefined)}>Retry</button>
|
||||
</span> : null}
|
||||
</div>
|
||||
{failed && typeof info.error === "string" ? <p className="lite-error" role="alert">{info.error}</p> : null}
|
||||
</div>
|
||||
</article>;
|
||||
})}
|
||||
{room.isTyping ? <p className="lite-status" role="status">Sybil is thinking…</p> : null}
|
||||
<div id="lite-agent-end" ref={end} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lite-composer-shell">
|
||||
<form className="lite-composer" onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label className="lite-visually-hidden" htmlFor="lite-agent-message">Message Sybil Agent</label>
|
||||
<textarea id="lite-agent-message" rows={1} maxLength={40000} value={draft} disabled={sending} placeholder="Message Sybil Agent…"
|
||||
onInput={event => updateDraft(event.currentTarget.value)}
|
||||
onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void submit(); } }} />
|
||||
{uploads.length ? <div className="lite-attachments">
|
||||
<p>Attachments saved with your draft</p>
|
||||
<ul>{uploads.map(file => <li key={file.uploadId}>
|
||||
{file.filename} — {file.status === "ready" ? "Ready to send" : "Open the full interface to resume this upload"}{" "}
|
||||
<button type="button" disabled={sending} onClick={() => {
|
||||
try { room.uploadQueue.remove(file.uploadId); setSendError(null); }
|
||||
catch (error) { setSendError(error instanceof Error ? error.message : String(error)); }
|
||||
}}>Remove</button>
|
||||
</li>)}</ul>
|
||||
</div> : null}
|
||||
<div className="lite-composer-footer">
|
||||
<button className="lite-primary lite-send" type="submit" aria-label="Send message" disabled={sending || room.isLoading || pendingUploads || (!draft.trim() && !uploads.length)}>
|
||||
<SendHorizontal size={19} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{sendError ? <p className="lite-error" role="alert">{sendError}</p> : null}
|
||||
</form>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import {
|
||||
attachCompletionStream, attachSearchStream, createChat, createSearch, getActiveRuns, getChat, getSearch,
|
||||
listModels, listWorkspaceItems, runCompletionStream, runSearchStream, suggestChatTitle,
|
||||
type ActiveRunsResponse, type ChatDetail, type ModelCatalogResponse, type Provider, type SearchDetail, type WorkspaceItem,
|
||||
} from "@/lib/api";
|
||||
import { getChatModelSelection, getChatModelSelectionSyncKey } from "@/lib/chat-model-selection";
|
||||
import { shouldRequestChatTitle } from "@/lib/chat-forking";
|
||||
import type { SidebarSelection } from "@/lib/sidebar-selection";
|
||||
import {
|
||||
buildCompletionMessages, buildWorkspaceUrl, createClientRequestId, EMPTY_MODEL_CATALOG,
|
||||
getModelOptions, getVisibleProviders, hasNewPersistedUserMessage, isRecoverableStreamDisconnect,
|
||||
loadStoredModelPreferences, pickProviderModel, readSidebarSelectionFromUrl, saveModelPreferences,
|
||||
} from "@/lib/workspace-domain";
|
||||
|
||||
const NO_RUNS: ActiveRunsResponse = { chats: [], searches: [] };
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
const sameSelection = (left: SidebarSelection | null, right: SidebarSelection | null) => left?.kind === right?.kind && left?.id === right?.id;
|
||||
|
||||
/** Lightweight presentation adapter: reuse the API and domain rules, publish completed responses only. */
|
||||
export function useLiteWorkspace(isAuthenticated: boolean, onAuthFailure: (message: string) => void) {
|
||||
const [selection, setSelection] = useState<SidebarSelection | null>(readSidebarSelectionFromUrl);
|
||||
const [draftKind, setDraftKind] = useState<"chat" | "search">(() => new URLSearchParams(window.location.search).has("q") ? "search" : "chat");
|
||||
const [items, setItems] = useState<WorkspaceItem[]>([]);
|
||||
const [chat, setChat] = useState<ChatDetail | null>(null);
|
||||
const [search, setSearch] = useState<SearchDetail | null>(null);
|
||||
const [catalog, setCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
||||
const [preferences, setPreferences] = useState(loadStoredModelPreferences);
|
||||
const [provider, updateProvider] = useState<Provider>("openai");
|
||||
const [model, updateModel] = useState(() => loadStoredModelPreferences().openai ?? "");
|
||||
const [activeRuns, setActiveRuns] = useState(NO_RUNS);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [navigationRevision, setNavigationRevision] = useState(0);
|
||||
const selected = useRef(selection);
|
||||
const authenticated = useRef(isAuthenticated); authenticated.current = isAuthenticated;
|
||||
const reportAuthFailure = useRef(onAuthFailure); reportAuthFailure.current = onAuthFailure;
|
||||
const mounted = useRef(true);
|
||||
const epoch = useRef(0);
|
||||
const loadRevision = useRef(0);
|
||||
const operation = useRef<AbortController | null>(null);
|
||||
const pendingRequest = useRef<Parameters<typeof runCompletionStream>[0] | null>(null);
|
||||
const modelSelectionSyncKey = useRef<string | null>(null);
|
||||
|
||||
const isCurrent = (requestEpoch: number) => mounted.current && authenticated.current && epoch.current === requestEpoch;
|
||||
const report = (failure: unknown, requestEpoch: number) => {
|
||||
if (!isCurrent(requestEpoch)) return;
|
||||
const message = errorText(failure);
|
||||
if (message.includes("bearer token")) reportAuthFailure.current(message);
|
||||
else setError(message);
|
||||
};
|
||||
const rememberModel = (nextProvider: Provider, nextModel: string) => {
|
||||
updateProvider(nextProvider); updateModel(nextModel);
|
||||
setPreferences(current => {
|
||||
const next = { ...current, [nextProvider]: nextModel.trim() || null };
|
||||
saveModelPreferences(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const applyChat = (detail: ChatDetail, syncModel = false) => {
|
||||
setChat(detail); setSearch(null);
|
||||
const saved = getChatModelSelection(detail);
|
||||
const syncKey = getChatModelSelectionSyncKey(detail.id, saved);
|
||||
if (syncModel && saved && syncKey !== modelSelectionSyncKey.current) rememberModel(saved.provider, saved.model);
|
||||
modelSelectionSyncKey.current = syncKey;
|
||||
};
|
||||
const select = (target: SidebarSelection | null, history: "push" | "replace" | null = "push") => {
|
||||
if (!sameSelection(selected.current, target)) modelSelectionSyncKey.current = null;
|
||||
selected.current = target; setSelection(target); setChat(null); setSearch(null); setError(null);
|
||||
loadRevision.current += 1;
|
||||
if (history) window.history[history === "push" ? "pushState" : "replaceState"]({}, "", buildWorkspaceUrl(target));
|
||||
};
|
||||
const updateItems = (item: WorkspaceItem) => setItems(current => [item, ...current.filter(existing => existing.type !== item.type || existing.id !== item.id)]);
|
||||
|
||||
// Attach to backend-owned runs after navigation or reload. No timer or repeated polling is needed.
|
||||
const attach = async (target: SidebarSelection, requestEpoch: number) => {
|
||||
if (target.kind === "agent" || operation.current || !isCurrent(requestEpoch)) return;
|
||||
const controller = new AbortController(); operation.current = controller; setIsSending(true);
|
||||
try {
|
||||
let streamError: string | null = null;
|
||||
const handlers = { onError: (payload: { message: string }) => { streamError = payload.message; } };
|
||||
if (target.kind === "chat") await attachCompletionStream(target.id, handlers, { signal: controller.signal });
|
||||
else await attachSearchStream(target.id, handlers, { signal: controller.signal });
|
||||
if (!isCurrent(requestEpoch)) return;
|
||||
if (streamError) throw new Error(streamError);
|
||||
const detail = target.kind === "chat" ? await getChat(target.id) : await getSearch(target.id);
|
||||
if (!isCurrent(requestEpoch)) return;
|
||||
if (sameSelection(selected.current, target)) {
|
||||
if (target.kind === "chat") applyChat(detail as ChatDetail, true);
|
||||
else setSearch(detail as SearchDetail);
|
||||
}
|
||||
updateItems({ ...detail, type: target.kind } as WorkspaceItem);
|
||||
setActiveRuns(current => ({
|
||||
chats: target.kind === "chat" ? current.chats.filter(id => id !== target.id) : current.chats,
|
||||
searches: target.kind === "search" ? current.searches.filter(id => id !== target.id) : current.searches,
|
||||
}));
|
||||
} catch (failure) {
|
||||
if (!controller.signal.aborted && isCurrent(requestEpoch)) {
|
||||
try {
|
||||
// A run can finish between listing active runs and attaching. Recover its saved result.
|
||||
const [runs, detail] = await Promise.all([
|
||||
getActiveRuns(), target.kind === "chat" ? getChat(target.id) : getSearch(target.id),
|
||||
]);
|
||||
if (!isCurrent(requestEpoch)) return;
|
||||
setActiveRuns(runs);
|
||||
if (sameSelection(selected.current, target)) {
|
||||
if (target.kind === "chat") applyChat(detail as ChatDetail, true);
|
||||
else setSearch(detail as SearchDetail);
|
||||
}
|
||||
const remainsActive = (target.kind === "chat" ? runs.chats : runs.searches).includes(target.id);
|
||||
if (remainsActive || !/^active (chat|search) stream not found$/i.test(errorText(failure))) report(failure, requestEpoch);
|
||||
} catch { report(failure, requestEpoch); }
|
||||
}
|
||||
} finally {
|
||||
if (operation.current === controller) {
|
||||
operation.current = null;
|
||||
if (isCurrent(requestEpoch)) setIsSending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
if (!authenticated.current || operation.current) return;
|
||||
const requestEpoch = epoch.current;
|
||||
const revision = ++loadRevision.current;
|
||||
const target = selected.current;
|
||||
setIsLoading(true); setError(null);
|
||||
try {
|
||||
const [nextItems, runs, detail] = await Promise.all([
|
||||
listWorkspaceItems(), getActiveRuns(),
|
||||
target?.kind === "chat" ? getChat(target.id) : target?.kind === "search" ? getSearch(target.id) : Promise.resolve(null),
|
||||
]);
|
||||
if (!isCurrent(requestEpoch) || revision !== loadRevision.current) return;
|
||||
setItems(nextItems); setActiveRuns(runs);
|
||||
if (detail && target?.kind === "chat") applyChat(detail as ChatDetail, true);
|
||||
else if (detail && target?.kind === "search") setSearch(detail as SearchDetail);
|
||||
if (target && (target.kind === "chat" ? runs.chats.includes(target.id) : target.kind === "search" && runs.searches.includes(target.id))) {
|
||||
void attach(target, requestEpoch);
|
||||
}
|
||||
} catch (failure) { if (revision === loadRevision.current) report(failure, requestEpoch); }
|
||||
finally { if (isCurrent(requestEpoch) && revision === loadRevision.current) setIsLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; epoch.current += 1; operation.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
epoch.current += 1;
|
||||
operation.current?.abort(); operation.current = null;
|
||||
pendingRequest.current = null;
|
||||
modelSelectionSyncKey.current = null;
|
||||
setIsSending(false); setIsLoading(false);
|
||||
if (!isAuthenticated) {
|
||||
setItems([]); setChat(null); setSearch(null); setActiveRuns(NO_RUNS); setError(null);
|
||||
return;
|
||||
}
|
||||
const requestEpoch = epoch.current;
|
||||
void listModels().then(result => {
|
||||
if (!isCurrent(requestEpoch)) return;
|
||||
setCatalog(result.providers);
|
||||
updateModel(current => current || pickProviderModel(getModelOptions(result.providers, provider), preferences[provider]));
|
||||
}).catch(failure => report(failure, requestEpoch));
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => { if (isAuthenticated) void refresh(); }, [isAuthenticated, selection?.kind, selection?.id, navigationRevision]);
|
||||
useEffect(() => {
|
||||
const navigate = () => {
|
||||
operation.current?.abort(); operation.current = null; setIsSending(false);
|
||||
pendingRequest.current = null;
|
||||
// Retire callbacks from a previous view, even if its fetch resolves after abort.
|
||||
epoch.current += 1;
|
||||
select(readSidebarSelectionFromUrl(), null);
|
||||
setNavigationRevision(value => value + 1);
|
||||
setDraftKind(new URLSearchParams(window.location.search).has("q") ? "search" : "chat");
|
||||
};
|
||||
window.addEventListener("popstate", navigate);
|
||||
return () => window.removeEventListener("popstate", navigate);
|
||||
}, []);
|
||||
|
||||
const selectItem = (target: SidebarSelection) => {
|
||||
if (!operation.current && !sameSelection(selected.current, target)) select(target);
|
||||
};
|
||||
const startDraft = (kind: "chat" | "search") => {
|
||||
if (operation.current) return;
|
||||
pendingRequest.current = null;
|
||||
setDraftKind(kind); select(null);
|
||||
// A fresh draft can keep the same null selection while retiring an in-flight load.
|
||||
setNavigationRevision(value => value + 1);
|
||||
// The standalone search query is a prefill, not a persistent mode flag.
|
||||
const url = new URL(window.location.href); url.searchParams.delete("q");
|
||||
window.history.replaceState({}, "", url.pathname + url.search + url.hash);
|
||||
};
|
||||
const setProvider = (next: Provider) => {
|
||||
if (!operation.current) rememberModel(next, pickProviderModel(getModelOptions(catalog, next), preferences[next]));
|
||||
};
|
||||
const setModel = (next: string) => { if (!operation.current) rememberModel(provider, next); };
|
||||
|
||||
const send = async (text: string): Promise<boolean> => {
|
||||
const content = text.trim();
|
||||
if (!content || !authenticated.current || operation.current || isLoading || selected.current?.kind === "agent") return false;
|
||||
const running = selected.current?.kind === "chat" ? activeRuns.chats : activeRuns.searches;
|
||||
if (selected.current && running.includes(selected.current.id)) return false;
|
||||
const requestEpoch = epoch.current;
|
||||
const controller = new AbortController(); operation.current = controller;
|
||||
setIsSending(true); setError(null);
|
||||
let target = selected.current;
|
||||
let baseChat: ChatDetail | null = null;
|
||||
let accepted = false;
|
||||
try {
|
||||
const kind = target?.kind ?? draftKind;
|
||||
if (kind === "search") {
|
||||
if (!target) {
|
||||
const created = await createSearch({ query: content, title: content.slice(0, 80) });
|
||||
if (!isCurrent(requestEpoch)) return false;
|
||||
target = { kind: "search", id: created.id }; select(target, "replace"); updateItems({ ...created, type: "search" });
|
||||
}
|
||||
let completed: SearchDetail | null = null;
|
||||
let streamError: string | null = null;
|
||||
await runSearchStream(target.id, { query: content, title: content.slice(0, 80), type: "auto", numResults: 10 }, {
|
||||
onDone: payload => { completed = payload.search; },
|
||||
onError: payload => { streamError = payload.message; },
|
||||
}, { signal: controller.signal });
|
||||
if (streamError) throw new Error(streamError);
|
||||
const detail: SearchDetail = completed ?? await getSearch(target.id);
|
||||
if (!isCurrent(requestEpoch)) return false;
|
||||
setSearch(detail); updateItems({ ...detail, type: "search" });
|
||||
accepted = true;
|
||||
} else {
|
||||
const selectedModel = model.trim();
|
||||
if (!selectedModel) throw new Error("Enter a model before sending.");
|
||||
if (!target) {
|
||||
const created = await createChat({ provider, model: selectedModel });
|
||||
if (!isCurrent(requestEpoch)) return false;
|
||||
target = { kind: "chat", id: created.id }; select(target, "replace"); updateItems({ ...created, type: "chat" });
|
||||
baseChat = { ...created, messages: [] };
|
||||
} else baseChat = chat?.id === target.id ? chat : await getChat(target.id);
|
||||
if (!isCurrent(requestEpoch)) return false;
|
||||
applyChat(baseChat);
|
||||
const previousMessageIds = new Set(baseChat.messages.map(message => message.id));
|
||||
const previous = pendingRequest.current;
|
||||
const lastMessage = previous?.messages[previous.messages.length - 1];
|
||||
const request = previous?.chatId === target.id && lastMessage?.content === content && previous.provider === provider && previous.model === selectedModel
|
||||
? previous
|
||||
: { chatId: target.id, clientRequestId: createClientRequestId(), provider, model: selectedModel, messages: buildCompletionMessages(baseChat.messages, content) };
|
||||
pendingRequest.current = request;
|
||||
if (shouldRequestChatTitle(baseChat, false) && !content.toLowerCase().includes("@sybil")) {
|
||||
void suggestChatTitle({ chatId: target.id, content }).catch(() => undefined);
|
||||
}
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
let streamError: string | null = null;
|
||||
try {
|
||||
await runCompletionStream(request, { onError: payload => { streamError = payload.message; } }, { signal: controller.signal });
|
||||
if (streamError) throw new Error(streamError);
|
||||
accepted = true;
|
||||
pendingRequest.current = null;
|
||||
break;
|
||||
} catch (failure) {
|
||||
if (controller.signal.aborted || !isCurrent(requestEpoch)) return false;
|
||||
// Replay the identical idempotent request once after transport loss.
|
||||
if (attempt === 0 && !streamError && isRecoverableStreamDisconnect(failure)) continue;
|
||||
try {
|
||||
const persisted = await getChat(target.id);
|
||||
accepted = hasNewPersistedUserMessage(persisted, previousMessageIds, content, []);
|
||||
if (isCurrent(requestEpoch)) applyChat(persisted);
|
||||
if (accepted) pendingRequest.current = null;
|
||||
} catch { /* Retain the exact request for a safe manual retry. */ }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
const detail = await getChat(target.id);
|
||||
if (!isCurrent(requestEpoch)) return false;
|
||||
applyChat(detail); updateItems({ ...detail, type: "chat" });
|
||||
}
|
||||
} catch (failure) {
|
||||
if (!controller.signal.aborted) report(failure, requestEpoch);
|
||||
} finally {
|
||||
if (operation.current === controller) {
|
||||
operation.current = null;
|
||||
if (isCurrent(requestEpoch)) {
|
||||
setIsSending(false);
|
||||
// Refresh active state after failures too, so a surviving backend run cannot be submitted twice.
|
||||
void getActiveRuns().then(runs => { if (isCurrent(requestEpoch)) setActiveRuns(runs); }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
};
|
||||
|
||||
const selectedRun = selection?.kind === "chat" ? activeRuns.chats.includes(selection.id)
|
||||
: selection?.kind === "search" ? activeRuns.searches.includes(selection.id) : false;
|
||||
return {
|
||||
selection, draftKind, items, chat, search, provider, model,
|
||||
providerOptions: getVisibleProviders(catalog), modelOptions: getModelOptions(catalog, provider),
|
||||
isLoading, isBusy: isSending || selectedRun, isSending, error, activeRuns,
|
||||
selectItem, newChat: () => startDraft("chat"), newSearch: () => startDraft("search"),
|
||||
setProvider, setModel, send, refresh,
|
||||
};
|
||||
}
|
||||
@@ -7,15 +7,17 @@ const TOKEN_STORAGE_KEY = "sybil_admin_token";
|
||||
export type AuthMode = "open" | "token";
|
||||
|
||||
function readStoredToken() {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)?.trim() || null;
|
||||
try { return localStorage.getItem(TOKEN_STORAGE_KEY)?.trim() || null; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
function persistToken(token: string | null) {
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
return;
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
} catch {
|
||||
// Browsers that disable storage can still keep a session in this document.
|
||||
}
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function normalizeAuthError(message: string) {
|
||||
@@ -28,7 +30,7 @@ function normalizeAuthError(message: string) {
|
||||
return message;
|
||||
}
|
||||
|
||||
export function useSessionAuth() {
|
||||
export function useSessionAuth({ backgroundSession = true }: { backgroundSession?: boolean } = {}) {
|
||||
const initialToken = readStoredToken() ?? getConfiguredToken() ?? "";
|
||||
|
||||
const [authTokenInput, setAuthTokenInput] = useState(initialToken);
|
||||
@@ -81,9 +83,9 @@ export function useSessionAuth() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || authMode !== "token") return;
|
||||
if (!backgroundSession || !isAuthenticated || authMode !== "token") return;
|
||||
return startAgentBrowserSession();
|
||||
}, [isAuthenticated, authMode]);
|
||||
}, [isAuthenticated, authMode, backgroundSession]);
|
||||
|
||||
useEffect(() => {
|
||||
// Signing out in one tab retires the others instead of using a stale token.
|
||||
|
||||
+2
-48
@@ -1,17 +1,9 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Orbitron:wght@700;800;900&display=swap");
|
||||
@import "./components/chat/message-bubble.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "StalinistOne";
|
||||
src: url("/StalinistOne-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
@@ -61,7 +53,7 @@ body {
|
||||
background-image:
|
||||
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
||||
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
||||
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||
font-family: var(--font-body, "Inter", "Avenir Next", "Segoe UI", sans-serif);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
@@ -80,13 +72,6 @@ textarea {
|
||||
color: hsl(258 36% 98%);
|
||||
}
|
||||
|
||||
.sybil-wordmark {
|
||||
font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-grid-surface {
|
||||
background-image:
|
||||
linear-gradient(hsl(244 48% 20% / 0.18) 1px, transparent 1px),
|
||||
@@ -194,37 +179,6 @@ textarea {
|
||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||
}
|
||||
|
||||
/* Shared by regular chats and the persistent agent transcript. */
|
||||
.message-bubble {
|
||||
min-width: 0;
|
||||
padding: 13px 17px;
|
||||
border: 1px solid hsl(256 37% 57% / .18);
|
||||
border-radius: 18px 18px 18px 5px;
|
||||
background: linear-gradient(150deg, hsl(251 36% 15% / .93), hsl(244 35% 11% / .93));
|
||||
box-shadow: 0 3px 12px hsl(239 55% 2% / .13);
|
||||
font-size: 14px;
|
||||
line-height: 1.68;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-bubble--user {
|
||||
border-color: hsl(266 80% 72% / .3);
|
||||
border-radius: 18px 18px 5px 18px;
|
||||
background: linear-gradient(135deg, hsl(264 59% 33% / .86), hsl(259 58% 26% / .9));
|
||||
color: hsl(265 55% 97%);
|
||||
}
|
||||
|
||||
.message-bubble .md-content {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.message-bubble {
|
||||
padding: 11px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-call-stack-shell {
|
||||
perspective: 900px;
|
||||
transform-style: preserve-3d;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { browserUuid } from "./browser-uuid.ts";
|
||||
|
||||
/** Agent descriptors are independent of ordinary chat's inline attachment schema. */
|
||||
export type AgentAttachment = {
|
||||
id: string; kind: "image" | "text" | "file"; filename: string; mimeType: string;
|
||||
@@ -104,7 +106,7 @@ export function createAgentUploadQueue(options: {
|
||||
const limit = image ? Math.min(6 * 1024 * 1024, cap.maxImageBytes) : Math.min(8 * 1024 * 1024, cap.maxFileBytes);
|
||||
if (file.size > limit) throw new Error(`${file.name} exceeds the ${image ? "6" : "8"} MiB file limit.`);
|
||||
}
|
||||
const added = selected.map(file => ({ uploadId: (options.newId || (() => crypto.randomUUID()))(), file, filename: file.name, sizeBytes: file.size, mimeType: file.type, status: "waiting" as const }));
|
||||
const added = selected.map(file => ({ uploadId: (options.newId || browserUuid)(), file, filename: file.name, sizeBytes: file.size, mimeType: file.type, status: "waiting" as const }));
|
||||
for (const row of added) rows.set(row.uploadId, row);
|
||||
try { persist(); } catch (error) { for (const row of added) rows.delete(row.uploadId); throw error; }
|
||||
publish(); pump();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Message } from "./api";
|
||||
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
|
||||
import { compareAgentMessages, normalizeAgentMessage } from "./agent-messages.ts";
|
||||
import { browserUuid } from "./browser-uuid.ts";
|
||||
|
||||
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
|
||||
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
|
||||
@@ -28,7 +29,7 @@ export function createAgentOptimisticOutbox(options: {
|
||||
newId?: () => string;
|
||||
now?: () => number;
|
||||
}) {
|
||||
const newId = options.newId || (() => crypto.randomUUID());
|
||||
const newId = options.newId || browserUuid;
|
||||
const now = options.now || Date.now;
|
||||
const rows = new Map<string, AgentOutboxRow>();
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { getApiBaseUrl, getAuthToken } from "./api";
|
||||
import { ensureServiceWorker } from "../pwa";
|
||||
import { browserPushOperation } from "./agent-push-errors.ts";
|
||||
import { browserUuid } from "./browser-uuid.ts";
|
||||
import {
|
||||
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
|
||||
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
|
||||
} from "./agent-push.ts";
|
||||
|
||||
let sessionId: string | undefined;
|
||||
function browserUuid() {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return crypto.randomUUID();
|
||||
if (typeof globalThis.crypto?.getRandomValues !== "function") throw new Error("Secure browser identifiers are unavailable.");
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = bytes[6] & 15 | 64; bytes[8] = bytes[8] & 63 | 128;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
/** A document UUID avoids duplicated sessionStorage IDs when the user duplicates a tab. */
|
||||
export function getAgentPresenceSessionId() { try { return sessionId ||= browserUuid(); } catch { return undefined; } }
|
||||
const api = new AgentPushClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
|
||||
|
||||
@@ -40,6 +40,10 @@ export type AgentRoomEvent =
|
||||
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : undefined;
|
||||
const defined = (value: Record<string, unknown>) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
function checkAborted(signal?: AbortSignal) {
|
||||
// AbortSignal.throwIfAborted() is newer than the browsers supported by Lite.
|
||||
if (signal?.aborted) throw signal.reason ?? new DOMException("Download canceled.", "AbortError");
|
||||
}
|
||||
export function normalizeAgentCursor(value: unknown): string {
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("Invalid agent message cursor.");
|
||||
return BigInt(value).toString();
|
||||
@@ -239,7 +243,7 @@ export class AgentRoomClient {
|
||||
}
|
||||
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
|
||||
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
|
||||
signal?.throwIfAborted();
|
||||
checkAborted(signal);
|
||||
while (this.activeDownloads >= 2) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => { this.downloadWaiters.delete(wake); signal?.removeEventListener("abort", abort); };
|
||||
@@ -248,7 +252,7 @@ export class AgentRoomClient {
|
||||
this.downloadWaiters.add(wake); signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
checkAborted(signal);
|
||||
}
|
||||
this.activeDownloads++;
|
||||
try {
|
||||
@@ -262,7 +266,7 @@ export class AgentRoomClient {
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
signal?.throwIfAborted();
|
||||
checkAborted(signal);
|
||||
if (size + value.length > bytes.length) throw new Error("Attachment download exceeds its size limit.");
|
||||
bytes.set(value, size); size += value.length;
|
||||
}
|
||||
@@ -272,7 +276,7 @@ export class AgentRoomClient {
|
||||
if (sha256 !== file.sha256) throw new Error("Attachment checksum did not match.");
|
||||
if (file.kind === "image" && response.headers.get("content-type")?.split(";")[0].trim() !== file.mimeType)
|
||||
throw new Error("Attachment image type did not match.");
|
||||
signal?.throwIfAborted();
|
||||
checkAborted(signal);
|
||||
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
|
||||
return new Blob([bytes], { type: response.headers.get("content-type") || "application/octet-stream" });
|
||||
} finally {
|
||||
|
||||
+1
-42
@@ -411,48 +411,7 @@ export async function deleteSearch(searchId: string) {
|
||||
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function getMessageAttachments(metadata: unknown): ChatAttachment[] {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
|
||||
const attachments = (metadata as Record<string, unknown>).attachments;
|
||||
if (!Array.isArray(attachments)) return [];
|
||||
|
||||
const parsed: ChatAttachment[] = [];
|
||||
for (const entry of attachments) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const kind = record.kind;
|
||||
const id = typeof record.id === "string" ? record.id : "";
|
||||
const filename = typeof record.filename === "string" ? record.filename : "";
|
||||
const mimeType = typeof record.mimeType === "string" ? record.mimeType : "";
|
||||
const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : 0;
|
||||
|
||||
if (kind === "image" && typeof record.dataUrl === "string" && (mimeType === "image/png" || mimeType === "image/jpeg")) {
|
||||
parsed.push({
|
||||
kind,
|
||||
id,
|
||||
filename,
|
||||
mimeType,
|
||||
sizeBytes,
|
||||
dataUrl: record.dataUrl,
|
||||
} satisfies ChatImageAttachment);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind === "text" && typeof record.text === "string") {
|
||||
parsed.push({
|
||||
kind,
|
||||
id,
|
||||
filename,
|
||||
mimeType,
|
||||
sizeBytes,
|
||||
text: record.text,
|
||||
truncated: record.truncated === true,
|
||||
} satisfies ChatTextAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
export { getMessageAttachments } from "./chat-attachments";
|
||||
|
||||
type RunSearchStreamHandlers = {
|
||||
onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
type BrowserCrypto = Pick<Crypto, "getRandomValues"> & Partial<Pick<Crypto, "randomUUID">>;
|
||||
|
||||
/** Secure UUIDs also work on browsers that predate Crypto.randomUUID(). */
|
||||
export function browserUuid(source: BrowserCrypto | undefined = typeof crypto === "undefined" ? undefined : crypto): string {
|
||||
if (typeof source?.randomUUID === "function") return source.randomUUID();
|
||||
if (typeof source?.getRandomValues !== "function") throw new Error("Secure browser identifiers are unavailable.");
|
||||
const bytes = source.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = bytes[6] & 15 | 64;
|
||||
bytes[8] = bytes[8] & 63 | 128;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ChatAttachment, ChatImageAttachment, ChatTextAttachment } from "./api";
|
||||
|
||||
export function getMessageAttachments(metadata: unknown): ChatAttachment[] {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
|
||||
const attachments = (metadata as Record<string, unknown>).attachments;
|
||||
if (!Array.isArray(attachments)) return [];
|
||||
|
||||
const parsed: ChatAttachment[] = [];
|
||||
for (const entry of attachments) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const kind = record.kind;
|
||||
const id = typeof record.id === "string" ? record.id : "";
|
||||
const filename = typeof record.filename === "string" ? record.filename : "";
|
||||
const mimeType = typeof record.mimeType === "string" ? record.mimeType : "";
|
||||
const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : 0;
|
||||
|
||||
if (kind === "image" && typeof record.dataUrl === "string" && (mimeType === "image/png" || mimeType === "image/jpeg")) {
|
||||
parsed.push({
|
||||
kind,
|
||||
id,
|
||||
filename,
|
||||
mimeType,
|
||||
sizeBytes,
|
||||
dataUrl: record.dataUrl,
|
||||
} satisfies ChatImageAttachment);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind === "text" && typeof record.text === "string") {
|
||||
parsed.push({
|
||||
kind,
|
||||
id,
|
||||
filename,
|
||||
mimeType,
|
||||
sizeBytes,
|
||||
text: record.text,
|
||||
truncated: record.truncated === true,
|
||||
} satisfies ChatTextAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Marked uses at() internally, including on Chromium-based readers predating it. */
|
||||
export function installLiteCompatibility() {
|
||||
if (!("at" in Array.prototype)) {
|
||||
Object.defineProperty(Array.prototype, "at", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: function at(this: unknown[], index: number) {
|
||||
const offset = Math.trunc(Number(index)) || 0;
|
||||
const position = offset < 0 ? this.length + offset : offset;
|
||||
return position < 0 || position >= this.length ? undefined : this[position];
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type UiMode = "full" | "lite";
|
||||
export type UiLocation = Pick<Location, "pathname" | "search" | "hash">;
|
||||
type PreferenceStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
export const UI_MODE_STORAGE_KEY = "sybil:ui-mode";
|
||||
|
||||
export function parseUiMode(value: string | null): UiMode | null {
|
||||
return value === "full" || value === "lite" ? value : null;
|
||||
}
|
||||
|
||||
export function readUiModePreference(storage: PreferenceStorage | null): UiMode | null {
|
||||
try {
|
||||
return parseUiMode(storage?.getItem(UI_MODE_STORAGE_KEY) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistUiModePreference(storage: PreferenceStorage | null, mode: UiMode) {
|
||||
try {
|
||||
storage?.setItem(UI_MODE_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// The explicit URL still works when private browsing blocks storage.
|
||||
}
|
||||
}
|
||||
|
||||
export function prefersLiteUi({ userAgent = "", saveData = false }: { userAgent?: string; saveData?: boolean }) {
|
||||
// Silk is also a tablet/phone browser; it is not an e-ink signal by itself.
|
||||
const isReader = /\b(?:Kindle|Kobo|PocketBook)\b/i.test(userAgent)
|
||||
&& !/\b(?:Silk|Kindle Fire)\b/i.test(userAgent);
|
||||
return saveData || isReader;
|
||||
}
|
||||
|
||||
export function isLitePath(path: string) {
|
||||
return path === "/lite" || path === "/lite/";
|
||||
}
|
||||
|
||||
function fullPath(location: UiLocation) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const isStandaloneSearch = params.has("q") && !["chat", "search", "agent"].some(key => params.has(key));
|
||||
return location.pathname === "/search" || (isLitePath(location.pathname) && isStandaloneSearch) ? "/search" : "/";
|
||||
}
|
||||
|
||||
/** Ordinary links deliberately navigate documents, keeping the two stylesheets isolated. */
|
||||
export function buildUiModeUrl(mode: UiMode, location: UiLocation = window.location) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("ui", mode);
|
||||
return `${mode === "lite" ? "/lite" : fullPath(location)}?${params.toString()}${location.hash}`;
|
||||
}
|
||||
|
||||
/** Decide once at entry, before downloading either presentation. */
|
||||
export function resolveUiRoute(
|
||||
location: UiLocation,
|
||||
hints: { userAgent?: string; saveData?: boolean },
|
||||
storedPreference: UiMode | null,
|
||||
) {
|
||||
const explicit = parseUiMode(new URLSearchParams(location.search).get("ui"));
|
||||
const mode = explicit ?? (isLitePath(location.pathname) ? "lite"
|
||||
: location.pathname === "/" ? storedPreference ?? (prefersLiteUi(hints) ? "lite" : "full") : "full");
|
||||
const path = mode === "lite" ? "/lite" : isLitePath(location.pathname) ? fullPath(location) : location.pathname;
|
||||
return { mode, url: `${path}${location.search}${location.hash}`, preference: explicit };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { ChatAttachment, ChatDetail, CompletionRequestMessage, Message, ModelCatalogResponse, Provider } from "./api";
|
||||
import type { SidebarSelection } from "./sidebar-selection";
|
||||
import { getMessageAttachments } from "./chat-attachments.ts";
|
||||
import { QUICK_QUESTION_DEFAULT_MODEL } from "./quick-question.ts";
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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}` : ""}${window.location.hash}`;
|
||||
}
|
||||
|
||||
export const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||
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"],
|
||||
};
|
||||
|
||||
export 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 },
|
||||
};
|
||||
|
||||
export const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
|
||||
export const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||
|
||||
export const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
||||
|
||||
export type ProviderModelPreferences = Record<Provider, string | null>;
|
||||
|
||||
export const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
||||
openai: null,
|
||||
anthropic: null,
|
||||
xai: null,
|
||||
gemini: null,
|
||||
"hermes-agent": null,
|
||||
};
|
||||
|
||||
export function createClientRequestId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export 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")
|
||||
);
|
||||
}
|
||||
|
||||
export function hasNewPersistedUserMessage(chat: ChatDetail, previousMessageIds: Set<string>, 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));
|
||||
});
|
||||
}
|
||||
|
||||
export function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
|
||||
const providerModels = catalog[provider]?.models ?? [];
|
||||
if (providerModels.length) return providerModels;
|
||||
return PROVIDER_FALLBACK_MODELS[provider];
|
||||
}
|
||||
|
||||
export function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
|
||||
return ALL_PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
|
||||
}
|
||||
|
||||
export 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;
|
||||
return normalizeStoredModelPreferences(JSON.parse(raw));
|
||||
} catch {
|
||||
return EMPTY_MODEL_PREFERENCES;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeStoredProvider(value: unknown): Provider {
|
||||
return value === "anthropic" || value === "xai" || value === "gemini" || value === "openai" || value === "hermes-agent"
|
||||
? value
|
||||
: "openai";
|
||||
}
|
||||
|
||||
export function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES;
|
||||
const parsed = value as Partial<Record<Provider, unknown>>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function pickProviderModel(options: string[], preferred: string | null) {
|
||||
if (preferred?.trim()) return preferred.trim();
|
||||
return options[0] ?? "";
|
||||
}
|
||||
|
||||
/** Persisted tool activity is a UI log, not a provider conversation message. */
|
||||
export function buildCompletionMessages(messages: Message[], content: string, attachments: ChatAttachment[] = []): CompletionRequestMessage[] {
|
||||
return [
|
||||
...messages.filter(message => {
|
||||
const metadata = message.metadata;
|
||||
return !metadata || typeof metadata !== "object" || Array.isArray(metadata) || (metadata as Record<string, unknown>).kind !== "tool_call";
|
||||
}).map(message => {
|
||||
const savedAttachments = getMessageAttachments(message.metadata);
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
...(savedAttachments.length ? { attachments: savedAttachments } : {}),
|
||||
};
|
||||
}),
|
||||
{ role: "user", content, ...(attachments.length ? { attachments } : {}) },
|
||||
];
|
||||
}
|
||||
|
||||
export function saveModelPreferences(preferences: ProviderModelPreferences) {
|
||||
try { window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences)); }
|
||||
catch { /* Browsers with unavailable storage can still use this session. */ }
|
||||
}
|
||||
+26
-2
@@ -1,8 +1,32 @@
|
||||
import { render } from "preact";
|
||||
import { RootRouter } from "@/root-router";
|
||||
import { registerServiceWorker } from "@/pwa";
|
||||
import "./index.css";
|
||||
import { persistUiModePreference, readUiModePreference, resolveUiRoute } from "@/lib/ui-mode";
|
||||
import "./typography.css";
|
||||
|
||||
registerServiceWorker();
|
||||
// Both presentations use the same fonts. A blocked CDN does not block the UI.
|
||||
const fonts = document.createElement("link");
|
||||
fonts.rel = "stylesheet";
|
||||
fonts.href = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Orbitron:wght@700;800;900&display=swap";
|
||||
document.head.appendChild(fonts);
|
||||
|
||||
let storage: Storage | null = null;
|
||||
try { storage = window.localStorage; } catch { /* Storage may be unavailable in private browsing. */ }
|
||||
const connection = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;
|
||||
const route = resolveUiRoute(window.location, {
|
||||
userAgent: navigator.userAgent,
|
||||
saveData: connection?.saveData,
|
||||
}, readUiModePreference(storage));
|
||||
if (route.preference) persistUiModePreference(storage, route.preference);
|
||||
if (route.url !== `${window.location.pathname}${window.location.search}${window.location.hash}`) {
|
||||
window.history.replaceState(window.history.state, "", route.url);
|
||||
}
|
||||
|
||||
if (route.mode === "lite") {
|
||||
document.querySelector('meta[name="viewport"]')?.setAttribute("content", "width=device-width, initial-scale=1, viewport-fit=cover");
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", "#ffffff");
|
||||
} else {
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
render(<RootRouter />, document.getElementById("app")!);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { ComponentType } from "preact";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
import { Bot, LogOut, Menu, MessageSquare, Plus, RefreshCw, Search, SendHorizontal, Settings2, Star, X } from "lucide-preact";
|
||||
import { AssistantMessageFooter } from "@/components/chat/assistant-message-footer";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||
import { useLiteWorkspace } from "@/hooks/use-lite-workspace";
|
||||
import { useWorkspaceSearch } from "@/hooks/use-workspace-search";
|
||||
import { getMessageAttachments, type Message, type SearchDetail, type WorkspaceItem } from "@/lib/api";
|
||||
import { getProviderLabel } from "@/lib/message-generation";
|
||||
import { buildUiModeUrl } from "@/lib/ui-mode";
|
||||
import "./lite.css";
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
function LiteAgentPanel() {
|
||||
const [Agent, setAgent] = useState<ComponentType | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setFailed(false);
|
||||
void import("@/components/lite/lite-agent").then(module => {
|
||||
if (active) setAgent(() => module.LiteAgent);
|
||||
}, () => { if (active) setFailed(true); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
if (failed) return <div className="lite-error" role="alert"><p>Agent could not load. Check your connection and reload this page.</p><button onClick={() => window.location.reload()}>Reload</button></div>;
|
||||
return Agent ? <Agent /> : <p role="status">Opening Agent…</p>;
|
||||
}
|
||||
|
||||
function itemTitle(item: WorkspaceItem) {
|
||||
return item.title?.trim() || (item.type === "search" ? item.query : null) || (item.type === "chat" ? "New chat" : "New search");
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function webLink(value: string | undefined) {
|
||||
return value && /^https?:\/\//i.test(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function LiteMessage({ message }: { message: Message }) {
|
||||
const attachments = getMessageAttachments(message.metadata);
|
||||
if (message.role === "system") return null;
|
||||
if (message.role === "tool") return (
|
||||
<details className="lite-tool">
|
||||
<summary>{message.name || "Tool activity"}</summary>
|
||||
<pre>{message.content}</pre>
|
||||
</details>
|
||||
);
|
||||
return (
|
||||
<article className={`lite-message${message.role === "user" ? " lite-message-user" : ""}`} aria-label={message.role === "user" ? "Your message" : "Assistant message"}>
|
||||
<div className={`lite-message-content${message.role === "user" ? " message-bubble message-bubble--user" : ""}`}>
|
||||
{attachments.length > 0 && <ul className="lite-attachments" aria-label="Attachments">
|
||||
{attachments.map(file => <li key={file.id}>{file.filename}</li>)}
|
||||
</ul>}
|
||||
<MarkdownContent markdown={message.content} allowImages={false} openLinksInNewTab />
|
||||
{message.role === "assistant" && <AssistantMessageFooter message={message} />}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function LiteTranscript({ messages }: { messages: Message[] }) {
|
||||
const [count, setCount] = useState(PAGE_SIZE);
|
||||
const visible = messages.filter(message => message.role !== "system");
|
||||
return (
|
||||
<section className="lite-transcript" aria-label="Conversation">
|
||||
{visible.length > count && <button type="button" onClick={() => setCount(value => value + PAGE_SIZE)}>Show earlier messages ({visible.length - count})</button>}
|
||||
{visible.slice(-count).map(message => <LiteMessage key={message.id} message={message} />)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LiteSearch({ search }: { search: SearchDetail }) {
|
||||
return (
|
||||
<section className="lite-search-results" aria-label="Web search results">
|
||||
{search.error && <p className="lite-error" role="alert">{search.error}</p>}
|
||||
{search.answerError && <p className="lite-error" role="alert">{search.answerError}</p>}
|
||||
{search.answerText && <article className="lite-search-answer"><h2>Answer</h2><MarkdownContent markdown={search.answerText} allowImages={false} /></article>}
|
||||
{!!search.answerCitations?.length && <details className="lite-tool"><summary>Answer sources</summary><ul>
|
||||
{search.answerCitations.map((citation, index) => <li key={index}><a href={webLink(citation.url)}>{citation.title || citation.url || `Source ${index + 1}`}</a></li>)}
|
||||
</ul></details>}
|
||||
<ol className="lite-result-list">
|
||||
{search.results.map(result => <li key={result.id}>
|
||||
<h2><a href={webLink(result.url)}>{result.title || result.url}</a></h2>
|
||||
<p className="lite-result-url">{result.url}</p>
|
||||
{(result.highlights?.length || result.text) && <p>{(result.highlights?.join(" ") || result.text || "").slice(0, 600)}</p>}
|
||||
</li>)}
|
||||
</ol>
|
||||
{!search.results.length && !search.answerText && !search.error && <p>No results saved for this search.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiteRoutePage() {
|
||||
const auth = useSessionAuth({ backgroundSession: false });
|
||||
const workspace = useLiteWorkspace(auth.isAuthenticated, auth.handleAuthFailure);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [historyQuery, setHistoryQuery] = useState("");
|
||||
const [historyCount, setHistoryCount] = useState(PAGE_SIZE);
|
||||
const [draft, setDraft] = useState(() => new URLSearchParams(window.location.search).get("q") || "");
|
||||
const [submitted, setSubmitted] = useState("");
|
||||
const submitLock = useRef(false);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const followTranscript = useRef(true);
|
||||
const historySearch = useWorkspaceSearch(historyQuery, auth.isAuthenticated, workspace.items.length);
|
||||
const historyItems = historyQuery.trim() ? historySearch.items : workspace.items;
|
||||
const isAgent = workspace.selection?.kind === "agent";
|
||||
const isSearch = workspace.selection?.kind === "search" || (!workspace.selection && workspace.draftKind === "search");
|
||||
const title = isAgent ? "Sybil Agent" : isSearch ? workspace.search?.title || "New search" : workspace.chat?.title || "New chat";
|
||||
const selectedKey = workspace.selection ? `${workspace.selection.kind}:${workspace.selection.id}` : workspace.draftKind;
|
||||
const priorSelection = useRef(selectedKey);
|
||||
const lastMessageId = workspace.chat?.messages[workspace.chat.messages.length - 1]?.id;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${title} · Sybil Lite`;
|
||||
}, [title]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (priorSelection.current !== selectedKey && (!submitLock.current || !workspace.isSending)) {
|
||||
setDraft("");
|
||||
setSubmitted("");
|
||||
}
|
||||
priorSelection.current = selectedKey;
|
||||
}, [selectedKey, workspace.isSending]);
|
||||
|
||||
useEffect(() => { setHistoryCount(PAGE_SIZE); }, [historyQuery]);
|
||||
|
||||
useLayoutEffect(() => { followTranscript.current = true; }, [selectedKey]);
|
||||
useLayoutEffect(() => {
|
||||
const transcript = transcriptRef.current;
|
||||
if (transcript && followTranscript.current) transcript.scrollTop = isSearch ? 0 : transcript.scrollHeight;
|
||||
}, [selectedKey, lastMessageId, submitted, workspace.isBusy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarOpen) return;
|
||||
const close = (event: KeyboardEvent) => { if (event.key === "Escape") setSidebarOpen(false); };
|
||||
window.addEventListener("keydown", close);
|
||||
return () => window.removeEventListener("keydown", close);
|
||||
}, [sidebarOpen]);
|
||||
|
||||
const send = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || workspace.isBusy || submitLock.current) return;
|
||||
submitLock.current = true;
|
||||
followTranscript.current = true;
|
||||
setSubmitted(text);
|
||||
try {
|
||||
if (await workspace.send(text)) setDraft("");
|
||||
} finally {
|
||||
setSubmitted("");
|
||||
submitLock.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fullLink = <a href={buildUiModeUrl("full", window.location)}>Full interface</a>;
|
||||
const brand = <a className="lite-brand sybil-wordmark" href="/lite?ui=lite" aria-label="Sybil home">SYBIL</a>;
|
||||
|
||||
if (auth.isCheckingSession) return <div className="lite-auth">{brand}<p role="status">Checking session…</p></div>;
|
||||
|
||||
if (!auth.isAuthenticated) return (
|
||||
<main className="lite-auth">
|
||||
{brand}
|
||||
<h1>Sign in to Sybil</h1>
|
||||
<p>Enter your admin token.</p>
|
||||
<form onSubmit={event => { event.preventDefault(); if (auth.authTokenInput.trim()) void auth.handleSignIn(auth.authTokenInput.trim()); }}>
|
||||
<label htmlFor="lite-token">Admin token</label>
|
||||
<input id="lite-token" type="password" autoComplete="off" required value={auth.authTokenInput}
|
||||
onInput={event => auth.setAuthTokenInput(event.currentTarget.value)} disabled={auth.isSigningIn} />
|
||||
<button className="lite-primary" disabled={auth.isSigningIn || !auth.authTokenInput.trim()}>{auth.isSigningIn ? "Signing in…" : "Sign in"}</button>
|
||||
</form>
|
||||
{auth.authError && <p className="lite-error" role="alert">{auth.authError}</p>}
|
||||
{fullLink}
|
||||
</main>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="lite-shell">
|
||||
<a className="lite-skip" href="#lite-main">Skip to conversation</a>
|
||||
{sidebarOpen && <button type="button" className="lite-sidebar-backdrop" aria-label="Close sidebar" onClick={() => setSidebarOpen(false)} />}
|
||||
<aside id="lite-sidebar" className={`lite-sidebar${sidebarOpen ? " is-open" : ""}`} aria-label="Workspace">
|
||||
<header className="lite-sidebar-header">
|
||||
{brand}
|
||||
<button type="button" className="lite-icon-button lite-sidebar-close" aria-label="Close sidebar" onClick={() => setSidebarOpen(false)}><X aria-hidden="true" /></button>
|
||||
</header>
|
||||
<nav className="lite-workspace-actions" aria-label="Workspace actions">
|
||||
<button className="lite-primary" type="button" onClick={() => { workspace.newChat(); setDraft(""); setSidebarOpen(false); }} disabled={workspace.isSending}><Plus aria-hidden="true" />New chat</button>
|
||||
<button type="button" onClick={() => { workspace.newSearch(); setDraft(""); setSidebarOpen(false); }} disabled={workspace.isSending}><Search aria-hidden="true" />New search</button>
|
||||
<button className="lite-agent-button" type="button" aria-current={isAgent ? "page" : undefined} onClick={() => { workspace.selectItem({ kind: "agent", id: "root" }); setSidebarOpen(false); }} disabled={workspace.isSending}><Bot aria-hidden="true" />Sybil Agent</button>
|
||||
</nav>
|
||||
<div className="lite-history-search">
|
||||
<Search aria-hidden="true" />
|
||||
<label className="lite-visually-hidden" htmlFor="lite-history-query">Search history</label>
|
||||
<input id="lite-history-query" type="search" placeholder="Search chats and web searches" value={historyQuery} onInput={event => setHistoryQuery(event.currentTarget.value)} />
|
||||
</div>
|
||||
<section className="lite-history" aria-label="History">
|
||||
<h2>History</h2>
|
||||
{historySearch.error && historyQuery && <p className="lite-error" role="alert">{historySearch.error}</p>}
|
||||
{historySearch.isLoading && <p role="status">Searching history…</p>}
|
||||
<ul className="lite-history-list">
|
||||
{historyItems.slice(0, historyCount).map(item => <li key={`${item.type}:${item.id}`}>
|
||||
<button type="button" aria-current={workspace.selection?.kind === item.type && workspace.selection.id === item.id ? "page" : undefined}
|
||||
disabled={workspace.isSending} onClick={() => { workspace.selectItem({ kind: item.type, id: item.id }); setSidebarOpen(false); }}>
|
||||
{item.type === "chat" ? <MessageSquare aria-hidden="true" /> : <Search aria-hidden="true" />}
|
||||
<span className="lite-history-item-content"><span className="lite-history-title">{itemTitle(item)}{item.starred && <Star aria-label="Starred" />}</span>
|
||||
<time dateTime={item.updatedAt}>{formatDate(item.updatedAt)}</time>
|
||||
{historyQuery && item.searchSnippet && <small>{item.searchSnippet}</small>}
|
||||
</span>
|
||||
</button>
|
||||
</li>)}
|
||||
</ul>
|
||||
{historyItems.length > historyCount && <button type="button" onClick={() => setHistoryCount(value => value + PAGE_SIZE)}>More history</button>}
|
||||
{!historyItems.length && !workspace.isLoading && !historySearch.isLoading && <p className="lite-muted">{historyQuery ? "No matches." : "No chats or searches."}</p>}
|
||||
{historySearch.hasMore && <p className="lite-muted">Refine your search to see more matches.</p>}
|
||||
</section>
|
||||
<footer className="lite-sidebar-footer">
|
||||
{fullLink}
|
||||
<button type="button" className="lite-text-button" onClick={() => void auth.logout()}><LogOut aria-hidden="true" />Sign out</button>
|
||||
</footer>
|
||||
</aside>
|
||||
<main id="lite-main" className="lite-main" tabIndex={-1}>
|
||||
<header className="lite-workspace-header">
|
||||
<button type="button" className="lite-icon-button lite-sidebar-trigger" aria-label="Open sidebar" aria-expanded={sidebarOpen} aria-controls="lite-sidebar" onClick={() => setSidebarOpen(true)}><Menu aria-hidden="true" /></button>
|
||||
<div className="lite-workspace-title"><h1>{title}</h1>{!isSearch && !isAgent && <p>{getProviderLabel(workspace.provider)} · {workspace.model || "No model"}</p>}</div>
|
||||
{!isAgent && <div className="lite-header-actions">
|
||||
<button className="lite-icon-button" type="button" aria-label="Refresh" title="Refresh" onClick={() => void workspace.refresh()} disabled={workspace.isSending || workspace.isLoading}><RefreshCw aria-hidden="true" /></button>
|
||||
{!isSearch && <details className="lite-settings"><summary aria-label="Settings"><Settings2 aria-hidden="true" /><span>Settings</span></summary>
|
||||
<div className="lite-model-controls">
|
||||
<label>Provider<select value={workspace.provider} onChange={event => workspace.setProvider(event.currentTarget.value as typeof workspace.provider)} disabled={workspace.isBusy}>
|
||||
{workspace.providerOptions.map(provider => <option key={provider} value={provider}>{getProviderLabel(provider)}</option>)}
|
||||
</select></label>
|
||||
<label>Model<input list="lite-model-options" value={workspace.model} onInput={event => workspace.setModel(event.currentTarget.value)} disabled={workspace.isBusy} autoComplete="off" /></label>
|
||||
<datalist id="lite-model-options">{workspace.modelOptions.map(model => <option key={model} value={model} />)}</datalist>
|
||||
</div>
|
||||
</details>}
|
||||
</div>}
|
||||
</header>
|
||||
{isAgent ? <LiteAgentPanel /> : <>
|
||||
<div className="lite-scroll" ref={transcriptRef} tabIndex={0} aria-label={isSearch ? "Search results" : "Conversation"}
|
||||
onScroll={event => { const element = event.currentTarget; followTranscript.current = element.scrollHeight - element.scrollTop - element.clientHeight < 96; }}>
|
||||
{workspace.isLoading && <p className="lite-status" role="status">Loading…</p>}
|
||||
{workspace.chat && <LiteTranscript key={workspace.chat.id} messages={workspace.chat.messages} />}
|
||||
{workspace.search && <LiteSearch search={workspace.search} />}
|
||||
{submitted && <div className="lite-transcript"><article className="lite-message lite-message-user"><div className="lite-message-content message-bubble message-bubble--user"><p className="lite-plain-text">{submitted}</p></div></article></div>}
|
||||
{workspace.isBusy && <p className="lite-status" role="status">{isSearch ? "Searching…" : "Generating…"}{!workspace.isSending && " Refresh to reconnect."}</p>}
|
||||
</div>
|
||||
<div className="lite-composer-shell">
|
||||
{workspace.error && <p className="lite-error" role="alert">{workspace.error}</p>}
|
||||
<form className="lite-composer" onSubmit={send}>
|
||||
<label className="lite-visually-hidden" htmlFor="lite-draft">{isSearch ? "Search the web" : "Message"}</label>
|
||||
<textarea id="lite-draft" rows={2} value={draft} onInput={event => setDraft(event.currentTarget.value)}
|
||||
onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) void send(event); }}
|
||||
disabled={workspace.isBusy || workspace.isLoading} placeholder={isSearch ? "Search the web" : "Enter prompt..."} />
|
||||
<div className="lite-composer-footer">
|
||||
<button className="lite-primary lite-send" type="submit" aria-label={isSearch ? "Search" : "Send message"} disabled={!draft.trim() || workspace.isBusy || workspace.isLoading || (!isSearch && !workspace.model.trim())}>{isSearch ? <Search aria-hidden="true" /> : <SendHorizontal aria-hidden="true" />}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
@import "../components/chat/message-bubble.css";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
color: #211c2b;
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
--radius: 0px;
|
||||
--lite-border: #ada7b8;
|
||||
--lite-muted: #595064;
|
||||
--lite-accent: #50327c;
|
||||
--message-bubble-background: #f3f1f7;
|
||||
--message-bubble-border: #aaa2b6;
|
||||
--message-bubble-shadow: none;
|
||||
--message-bubble-user-background: #e9e0f5;
|
||||
--message-bubble-user-border: #9683b1;
|
||||
--message-bubble-user-color: #211c2b;
|
||||
--message-footer-color: var(--lite-muted);
|
||||
--message-footer-hover-background: #ede8f4;
|
||||
--message-footer-hover-color: #211c2b;
|
||||
--message-footer-focus-color: var(--lite-accent);
|
||||
--message-footer-transition: none;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; width: 100%; margin: 0; }
|
||||
body { overflow: hidden; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button, input, select, textarea, summary { border: 1px solid var(--lite-border); border-radius: var(--radius); background: #fff; }
|
||||
button { display: inline-flex; justify-content: center; align-items: center; gap: .5rem; cursor: pointer; padding: .5rem .75rem; min-height: 40px; font-size: .875rem; font-weight: 500; line-height: 1.4; }
|
||||
button:hover { background: #ede8f4; }
|
||||
button:disabled { cursor: default; color: #625b6d; background: #eae7ee; border-color: var(--lite-border); }
|
||||
button svg, summary svg { width: 1rem; height: 1rem; flex: none; }
|
||||
input, select, textarea { display: block; width: 100%; padding: .5rem .75rem; min-height: 40px; }
|
||||
textarea { resize: vertical; line-height: 1.5; }
|
||||
label { display: block; font-size: .875rem; font-weight: 500; }
|
||||
a { color: var(--lite-accent); text-decoration: underline; overflow-wrap: anywhere; }
|
||||
:focus-visible { outline: 2px solid var(--lite-accent); outline-offset: 2px; }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { font-size: 1rem; font-weight: 600; line-height: 1.5; }
|
||||
h2 { font-size: 1.1rem; line-height: 1.4; }
|
||||
small { font-size: .75rem; }
|
||||
summary { display: flex; align-items: center; gap: .5rem; cursor: pointer; padding: .5rem .75rem; min-height: 40px; font-size: .875rem; list-style: none; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
pre { max-width: 100%; overflow: auto; padding: .75rem; border: 1px solid var(--lite-border); border-radius: var(--radius); background: #f5f3f8; font-size: .875rem; }
|
||||
::selection { color: #fff; background: var(--lite-accent); }
|
||||
.lite-shell { display: flex; height: 100%; min-height: 0; padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0); }
|
||||
.lite-sidebar { display: flex; flex-direction: column; width: 320px; flex: none; min-height: 0; border-right: 1px solid var(--lite-border); background: #f4f1f8; }
|
||||
.lite-sidebar-header { display: flex; align-items: center; justify-content: space-between; min-height: 96px; padding: 20px 16px 16px; }
|
||||
.lite-brand { display: inline-block; color: var(--lite-accent); font-size: 30px; text-decoration: none; }
|
||||
.lite-workspace-actions { display: flex; flex-direction: column; gap: .75rem; padding: 0 12px 12px; }
|
||||
.lite-workspace-actions button { width: 100%; justify-content: flex-start; gap: .75rem; padding-left: .75rem; }
|
||||
.lite-workspace-actions button:first-child { min-height: 44px; font-size: 15px; }
|
||||
.lite-workspace-actions .lite-agent-button { min-height: 50px; border-color: #bcb5c7; background: #f4f1f8; font-weight: 600; }
|
||||
.lite-agent-button svg { width: 18px; height: 18px; }
|
||||
.lite-agent-button[aria-current="page"] { background: #e4d9f3; border-color: var(--lite-accent); }
|
||||
.lite-primary { color: #fff; background: var(--lite-accent); border-color: var(--lite-accent); }
|
||||
.lite-primary:hover { background: #402563; }
|
||||
.lite-history-search { position: relative; margin: 0 12px 12px; }
|
||||
.lite-history-search > svg { position: absolute; top: 12px; left: 12px; height: 16px; width: 16px; color: var(--lite-muted); }
|
||||
.lite-history-search input { padding-left: 36px; font-size: 14px; }
|
||||
.lite-history { flex: 1; min-height: 0; overflow-y: auto; padding: 0 12px 12px; }
|
||||
.lite-history h2 { margin: 12px 8px 8px; font-size: 10px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--lite-muted); }
|
||||
.lite-history-list { list-style: none; padding: 0; margin: 0; }
|
||||
.lite-history-list li { margin-bottom: 4px; }
|
||||
.lite-history-list button { width: 100%; justify-content: flex-start; align-items: flex-start; gap: .5rem; text-align: left; padding: 10px 12px; background: #f4f1f8; border-color: #f4f1f8; }
|
||||
.lite-history-list button:hover { background: #ede8f4; }
|
||||
.lite-history-list button[aria-current="page"] { border-color: var(--lite-accent); background: #e4d9f3; }
|
||||
.lite-history-list button > svg { margin-top: 2px; }
|
||||
.lite-history-item-content { min-width: 0; flex: 1; }
|
||||
.lite-history-title { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 14px; font-weight: 600; }
|
||||
.lite-history-title svg { display: inline; width: 12px; height: 12px; margin-left: 5px; fill: currentColor; }
|
||||
.lite-history-item-content time, .lite-history-item-content small { display: block; margin-top: 4px; color: var(--lite-muted); font-size: 12px; font-weight: 400; overflow-wrap: anywhere; }
|
||||
.lite-sidebar-footer { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--lite-border); padding: 8px 16px; font-size: 12px; }
|
||||
.lite-text-button { padding: .5rem 0; border: 0; background: none; font-size: inherit; }
|
||||
.lite-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; flex: 1; }
|
||||
.lite-workspace-header { display: flex; flex: none; align-items: center; gap: .5rem; min-height: 65px; padding: 12px 28px; border-bottom: 1px solid var(--lite-border); background: #f9f7fc; }
|
||||
.lite-workspace-title { flex: 1; min-width: 0; }
|
||||
.lite-workspace-title h1, .lite-workspace-title p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lite-workspace-title p { color: var(--lite-muted); font-size: 12px; }
|
||||
.lite-header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.lite-icon-button { width: 40px; padding: 0; flex: none; }
|
||||
.lite-settings { position: relative; }
|
||||
.lite-settings[open] summary { border-color: var(--lite-accent); }
|
||||
.lite-model-controls { position: absolute; top: calc(100% + 8px); right: 0; z-index: 10; width: 280px; padding: 16px; border: 1px solid var(--lite-border); border-radius: var(--radius); background: #fff; }
|
||||
.lite-model-controls label + label { margin-top: 12px; }
|
||||
.lite-model-controls input, .lite-model-controls select { margin-top: 4px; font-weight: 400; }
|
||||
.lite-scroll, .lite-agent-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 32px 40px 24px; }
|
||||
.lite-transcript, .lite-search-results { max-width: 896px; margin: 0 auto; min-width: 0; }
|
||||
.lite-message { display: flex; justify-content: flex-start; min-width: 0; margin-bottom: 24px; }
|
||||
.lite-message-user { justify-content: flex-end; }
|
||||
.lite-message-content { min-width: 0; max-width: 85%; overflow-wrap: anywhere; }
|
||||
.lite-message:not(.lite-message-user) > .lite-message-content { font-size: 16px; line-height: 1.82; }
|
||||
.lite-message-user > .lite-message-content { max-width: 78%; }
|
||||
.lite-message-content > .assistant-message-footer { margin-top: 12px; }
|
||||
.lite-message-meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 7px; margin: 5px 3px 0; color: var(--lite-muted); font-size: 11px; line-height: 1.4; }
|
||||
.lite-message-meta time::before { content: "·"; margin-right: 7px; }
|
||||
.lite-message-user .lite-message-meta { justify-content: flex-end; }
|
||||
.lite-plain-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.lite-tool { max-width: 896px; margin: 0 auto 24px; font-size: .875rem; overflow-wrap: anywhere; }
|
||||
.lite-tool summary { display: list-item; list-style: disclosure-closed inside; width: fit-content; max-width: 100%; }
|
||||
.lite-tool[open] summary { list-style-type: disclosure-open; }
|
||||
.lite-attachments { font-size: .875rem; padding-left: 1.25rem; margin: 8px 0; }
|
||||
.lite-composer-shell { flex: none; padding: 12px 24px 24px; background: #fff; }
|
||||
.lite-composer { max-width: 896px; margin: 0 auto; padding: 8px; border: 1px solid #9683b1; border-radius: var(--radius); background: #faf8fd; }
|
||||
.lite-composer textarea { max-height: 160px; min-height: 48px; padding: 12px; border: 0; border-radius: var(--radius); background: #faf8fd; font-size: 16px; resize: vertical; }
|
||||
.lite-composer textarea:focus { outline: 0; }
|
||||
.lite-composer:focus-within { outline: 2px solid var(--lite-accent); outline-offset: 2px; }
|
||||
.lite-composer-footer { display: flex; align-items: center; justify-content: flex-end; padding: 0 8px 4px; }
|
||||
.lite-send { width: 40px; height: 40px; padding: 0; }
|
||||
.lite-send svg { width: 18px; height: 18px; }
|
||||
.lite-muted { color: var(--lite-muted); font-size: 12px; }
|
||||
.lite-status { max-width: 896px; margin: 0 auto 16px; color: var(--lite-muted); font-size: 14px; }
|
||||
.lite-error { max-width: 896px; margin: 0 auto 12px; padding: 8px 12px; border: 1px solid #873535; border-radius: var(--radius); color: #732222; background: #fff; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.lite-error button { margin-top: 8px; }
|
||||
.lite-agent { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.lite-agent-connection { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 4px 28px; border-bottom: 1px solid var(--lite-border); font-size: 12px; }
|
||||
.lite-agent-connection button { margin-left: auto; border: 0; min-height: 32px; padding: 4px 0; background: none; font-size: 12px; }
|
||||
.lite-agent-scroll .lite-transcript { display: flex; flex-direction: column; justify-content: flex-end; min-height: 100%; max-width: 848px; }
|
||||
.lite-agent-scroll .lite-transcript > * { flex: none; }
|
||||
.lite-agent-scroll { overflow-anchor: none; }
|
||||
.lite-agent .lite-message { margin-bottom: 15px; }
|
||||
.lite-agent .lite-message-content { max-width: 89%; }
|
||||
.lite-agent .lite-message-user .lite-message-content { max-width: 78%; }
|
||||
.lite-agent .lite-composer { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px; max-width: 788px; padding: 7px 8px 7px 5px; border-radius: 17px; }
|
||||
.lite-agent .lite-composer textarea { flex: 1; min-width: 0; width: auto; min-height: 46px; max-height: 156px; resize: none; padding: 13px 11px; font-size: 14px; line-height: 20px; border-radius: 12px; }
|
||||
.lite-agent .lite-composer-footer { flex: none; padding: 0; }
|
||||
.lite-agent .lite-send { width: 38px; height: 38px; min-height: 38px; margin-bottom: 4px; border-radius: 11px; }
|
||||
.lite-agent .lite-composer > .lite-attachments, .lite-agent .lite-composer > .lite-error { flex-basis: 100%; }
|
||||
.lite-delivery-error { color: #732222; }
|
||||
.lite-delivery-error button { min-height: 0; padding: 0; border: 0; border-radius: 0; background: none; color: inherit; font-size: inherit; text-decoration: underline; }
|
||||
.lite-result-list { list-style: none; padding: 0; margin: 24px 0 0; }
|
||||
.lite-result-list li { margin-bottom: 24px; }
|
||||
.lite-result-list h2 { font-size: 18px; font-weight: 500; }
|
||||
.lite-result-url { margin: 4px 0 8px; font-size: 12px; color: var(--lite-muted); overflow-wrap: anywhere; }
|
||||
.lite-search-answer { padding-bottom: 16px; border-bottom: 1px solid var(--lite-border); margin-bottom: 16px; }
|
||||
.lite-search-answer h2 { margin-bottom: 12px; }
|
||||
.md-content { overflow-wrap: anywhere; }
|
||||
.md-content > :first-child { margin-top: 0; }
|
||||
.md-content > :last-child { margin-bottom: 0; }
|
||||
.md-content p + p { margin-top: .85rem; }
|
||||
.md-content h1, .md-content h2, .md-content h3 { margin: 1.2rem 0 .45rem; line-height: 1.25; font-weight: 700; }
|
||||
.md-content h1 { font-size: 1.45rem; }
|
||||
.md-content h2 { font-size: 1.25rem; }
|
||||
.md-content h3 { font-size: 1.1rem; }
|
||||
.md-content ul, .md-content ol { padding-left: 1.35rem; margin: .85rem 0; }
|
||||
.md-content li + li { margin-top: .3rem; }
|
||||
.md-content blockquote { border-left: 3px solid #aaa2b6; margin: 1rem 0; padding-left: 1rem; }
|
||||
.md-content code { font-size: .86em; border-radius: var(--radius); background: #ebe7f1; padding: .05rem .3rem; }
|
||||
.md-content pre { margin: 1rem 0; }
|
||||
.md-content pre code { display: block; background: none; padding: 0; font-size: .88em; line-height: 1.55; white-space: pre; }
|
||||
.md-table-scroll { max-width: 100%; overflow-x: auto; margin: .35rem 0 1rem; }
|
||||
.md-content table { border-collapse: collapse; min-width: 100%; font-size: .94em; line-height: 1.48; }
|
||||
.md-content td, .md-content th { border: 1px solid var(--lite-border); text-align: left; padding: .48rem .7rem; }
|
||||
.md-content th { background: #ede8f4; white-space: nowrap; }
|
||||
.lite-auth { max-width: 448px; margin: 15vh auto 0; padding: 24px; border: 1px solid var(--lite-border); border-radius: var(--radius); background: #f9f7fc; }
|
||||
.lite-auth .lite-brand { margin-bottom: 24px; }
|
||||
.lite-auth h1 { font-size: 18px; }
|
||||
.lite-auth p { margin-top: 4px; font-size: 14px; }
|
||||
.lite-auth form { margin: 20px 0 12px; }
|
||||
.lite-auth input { margin: 8px 0 12px; }
|
||||
.lite-auth .lite-primary { width: 100%; }
|
||||
.lite-auth > a:last-child { font-size: 12px; }
|
||||
.lite-sidebar-trigger, .lite-sidebar-close, .lite-sidebar-backdrop { display: none; }
|
||||
.lite-skip, .lite-visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
|
||||
.lite-skip:focus { z-index: 100; top: 0; left: 0; width: auto; height: auto; clip: auto; padding: .5rem; background: #fff; }
|
||||
@supports (height: 100dvh) { html, body, #app { height: 100dvh; } }
|
||||
@media (min-width: 1024px) { .lite-scroll, .lite-agent-scroll { padding-left: 56px; padding-right: 56px; } }
|
||||
@media (max-width: 767px) {
|
||||
.lite-sidebar { display: none; position: fixed; inset: 0 auto 0 0; z-index: 30; width: 86vw; max-width: 320px; }
|
||||
.lite-sidebar.is-open { display: flex; }
|
||||
.lite-sidebar-trigger, .lite-sidebar-close { display: inline-flex; background: none; border: 0; }
|
||||
.lite-sidebar-backdrop { display: block; position: fixed; inset: 0; z-index: 20; width: 100%; height: 100%; border: 0; border-radius: 0; background: #c9c3d3; }
|
||||
.lite-sidebar-backdrop:hover { background: #c9c3d3; }
|
||||
.lite-workspace-header { min-height: 65px; padding: 10px 12px; }
|
||||
.lite-workspace-title h1 { font-size: 14px; }
|
||||
.lite-header-actions { gap: 4px; }
|
||||
.lite-settings summary { justify-content: center; width: 40px; padding: 0; }
|
||||
.lite-settings summary > span { display: none; }
|
||||
.lite-model-controls { width: min(280px, calc(100vw - 24px)); }
|
||||
.lite-scroll, .lite-agent-scroll { padding: 24px 16px; }
|
||||
.lite-message-content { width: 100%; max-width: 100%; }
|
||||
.lite-message-user > .lite-message-content { width: auto; max-width: 87%; }
|
||||
.lite-composer-shell { padding: 8px 12px 12px; }
|
||||
.lite-agent-connection { padding: 4px 16px; }
|
||||
.lite-agent .lite-message-content { width: auto; }
|
||||
.lite-auth { margin: 8vh 16px 0; padding: 24px; max-width: none; }
|
||||
}
|
||||
+45
-8
@@ -1,18 +1,55 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import App from "@/App";
|
||||
import SearchRoutePage from "@/pages/search-route-page";
|
||||
import type { ComponentType } from "preact";
|
||||
import { buildUiModeUrl, isLitePath } from "@/lib/ui-mode";
|
||||
import { installLiteCompatibility } from "@/lib/lite-compat";
|
||||
|
||||
function routeName(path: string) {
|
||||
return isLitePath(path) ? "lite" : path === "/search" ? "search" : "full";
|
||||
}
|
||||
|
||||
async function loadPage(path: string) {
|
||||
if (isLitePath(path)) {
|
||||
installLiteCompatibility();
|
||||
return import("@/pages/lite-route-page");
|
||||
}
|
||||
const [page] = await Promise.all([
|
||||
path === "/search" ? import("@/pages/search-route-page") : import("@/App"),
|
||||
import("./index.css"),
|
||||
]);
|
||||
return page;
|
||||
}
|
||||
|
||||
export function RootRouter() {
|
||||
const [path, setPath] = useState(window.location.pathname);
|
||||
const [Page, setPage] = useState<ComponentType | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setPath(window.location.pathname);
|
||||
let disposed = false;
|
||||
const path = window.location.pathname;
|
||||
void loadPage(path).then(page => {
|
||||
if (!disposed) setPage(() => page.default);
|
||||
}, () => {
|
||||
if (!disposed) setFailed(true);
|
||||
});
|
||||
// Same-route history belongs to its page. Crossing presentations requires a
|
||||
// clean document so the full UI's global CSS cannot leak into Lite.
|
||||
const onPopState = () => {
|
||||
if (routeName(window.location.pathname) !== routeName(path)) window.location.reload();
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (path === "/search") {
|
||||
return <SearchRoutePage />;
|
||||
if (failed) {
|
||||
return <main style={{ maxWidth: "36rem", margin: "2rem auto", padding: "1rem", fontFamily: "sans-serif" }}>
|
||||
<h1>Sybil could not load</h1>
|
||||
<p>Check your connection, then reload this page.</p>
|
||||
<p><button onClick={() => window.location.reload()}>Reload</button></p>
|
||||
<p><a href={buildUiModeUrl("lite")}>Lightweight UI</a> · <a href={buildUiModeUrl("full")}>Full UI</a></p>
|
||||
</main>;
|
||||
}
|
||||
return <App />;
|
||||
return Page ? <Page /> : <p role="status" style={{ margin: "2rem", fontFamily: "sans-serif" }}>Loading Sybil…</p>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
@font-face {
|
||||
font-family: "StalinistOne";
|
||||
src: url("/StalinistOne-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-body: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||
--font-wordmark: "StalinistOne", "Orbitron", "Inter", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.sybil-wordmark {
|
||||
font-family: var(--font-wordmark);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -47,6 +47,18 @@ test('downloads bound concurrent bytes, validate hashes/types, cancel queued wor
|
||||
await assert.rejects(wrongType.download({...file,kind:'image',mimeType:'image/png'}),/type did not match/);
|
||||
});
|
||||
|
||||
test('downloads support older AbortSignals without throwIfAborted or reason', async () => {
|
||||
let calls = 0;
|
||||
const client = new AgentRoomClient({ fetch: async () => { calls++; return new Response('data'); } });
|
||||
const controller = new AbortController();
|
||||
Object.defineProperty(controller.signal, 'throwIfAborted', { value: undefined });
|
||||
Object.defineProperty(controller.signal, 'reason', { value: undefined });
|
||||
assert.equal(await (await client.download(file, controller.signal)).text(), 'data');
|
||||
controller.abort();
|
||||
await assert.rejects(client.download(file, controller.signal), error => error.name === 'AbortError');
|
||||
assert.equal(calls, 1, 'an already canceled download never starts another request');
|
||||
});
|
||||
|
||||
test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{
|
||||
let capability, attempts=[], next=0, changed=0, finish;
|
||||
const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { browserUuid } from "../src/lib/browser-uuid.ts";
|
||||
|
||||
test("native UUID generation keeps the crypto receiver", () => {
|
||||
const source = {
|
||||
randomUUID() { assert.equal(this, source); return "native-id"; },
|
||||
getRandomValues() { assert.fail("native generation should be used"); },
|
||||
};
|
||||
assert.equal(browserUuid(source), "native-id");
|
||||
});
|
||||
|
||||
test("older browsers generate RFC 4122 version 4 UUIDs using secure random bytes", () => {
|
||||
const source = { getRandomValues(bytes) {
|
||||
assert.equal(this, source);
|
||||
assert.equal(bytes.length, 16);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = i;
|
||||
return bytes;
|
||||
} };
|
||||
assert.equal(browserUuid(source), "00010203-0405-4607-8809-0a0b0c0d0e0f");
|
||||
});
|
||||
|
||||
test("missing secure randomness never falls back to a guessable identifier", () => {
|
||||
assert.throws(() => browserUuid({}), /Secure browser identifiers are unavailable/);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// Local production preview only; every API call and socket is mocked.
|
||||
// PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome
|
||||
// WEB_TEST_URL=http://127.0.0.1:5197 node tests/lite-agent.browser.mjs
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
||||
assert.ok(target.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname));
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE || undefined,
|
||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
|
||||
const context = await browser.newContext({ viewport: { width: 1000, height: 850 }, serviceWorkers: "block" });
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem("sybil_admin_token", "lite-agent-fixture-token");
|
||||
// Exercise missing APIs on Chromium readers without emulating their rendering engine.
|
||||
Object.defineProperty(Crypto.prototype, "randomUUID", { configurable: true, value: undefined });
|
||||
delete Array.prototype.at;
|
||||
delete AbortSignal.prototype.throwIfAborted;
|
||||
});
|
||||
|
||||
const errors = [], requests = [], submissions = [], downloads = [], persistedBeforeSend = [];
|
||||
const sourceId = "00000000-0000-4000-8000-000000000001";
|
||||
const date = "2026-11-10T12:00:00.000Z";
|
||||
const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
||||
const image = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=", "base64");
|
||||
const attachment = { id: "att_12345678-1234-4234-8234-123456789abc", kind: "image", filename: "fixture.png", mimeType: "image/png",
|
||||
sizeBytes: image.length, sha256: createHash("sha256").update(image).digest("hex") };
|
||||
const history = [{ id: "outbound:1", role: "assistant", content: "A static **Agent reply**.\n\n",
|
||||
createdAt: date, name: null, metadata: { kind: "message", status: "complete", entryId: 1, agentAttachments: [attachment] } }];
|
||||
let cursor = 1, readCursor = "0", failedSend = false, live;
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await context.route("**/*", async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname;
|
||||
requests.push(url.href);
|
||||
if (url.origin !== target.origin) return route.abort();
|
||||
if (!path.startsWith("/api/")) return route.continue();
|
||||
assert.equal(request.headers().authorization, "Bearer lite-agent-fixture-token");
|
||||
const method = request.method();
|
||||
let body;
|
||||
if (path.endsWith("/auth/session")) body = { authenticated: true, mode: "token" };
|
||||
else if (path.endsWith("/workspace-items")) body = { items: [] };
|
||||
else if (path.endsWith("/active-runs")) body = { chats: [], searches: [] };
|
||||
else if (path.endsWith("/models")) body = { providers: { openai: { models: ["fixture-model"], loadedAt: date, error: null } } };
|
||||
else if (path.endsWith("/chat-tools")) body = { tools: [] };
|
||||
else if (path.endsWith("/agent/messages") && method === "POST") {
|
||||
const submission = request.postDataJSON();
|
||||
submissions.push(submission);
|
||||
persistedBeforeSend.push(await page.evaluate(() => JSON.parse(sessionStorage.getItem("sybil.agent.optimistic-outbox.v1") || "[]")));
|
||||
if (!failedSend) { failedSend = true; return route.abort("internetdisconnected"); }
|
||||
const row = { id: `inbound:sybil:${submission.requestId}`, role: "user", content: submission.content,
|
||||
createdAt: date, name: null, metadata: { kind: "message", status: "complete", entryId: ++cursor } };
|
||||
history.push(row);
|
||||
live.send(JSON.stringify({ type: "messages", conversationId: "fixture-room", messages: [row], cursor: String(cursor), beforeCursor: String(cursor), hasMore: false }));
|
||||
body = { id: cursor, status: "placed", messageId: row.id };
|
||||
} else if (path.endsWith("/agent/messages")) {
|
||||
const after = url.searchParams.get("after");
|
||||
const messages = after === null ? history : history.filter(message => message.metadata.entryId > Number(after));
|
||||
body = { conversationId: "fixture-room", messages, cursor: String(cursor), beforeCursor: messages.length ? String(messages[0].metadata.entryId) : null, hasMore: false };
|
||||
} else if (path.endsWith("/read-state")) {
|
||||
if (method === "PUT") readCursor = request.postDataJSON().cursor;
|
||||
body = { sourceId, cursor: readCursor, updatedAt: null };
|
||||
} else if (path.endsWith("/agent")) body = { id: "fixture-room", title: "Sybil Agent", activity: idle, protocol: "pi-room.v1", websocket: "/v1/agent/ws" };
|
||||
else if (path.endsWith("/content")) {
|
||||
downloads.push(url.href);
|
||||
return route.fulfill({ contentType: "image/png", body: image });
|
||||
} else {
|
||||
assert.fail(`Unexpected API request ${method} ${path}`);
|
||||
}
|
||||
await route.fulfill({ json: body });
|
||||
});
|
||||
await context.routeWebSocket("**/api/v1/agent/ws", socket => {
|
||||
live = socket;
|
||||
socket.onMessage(raw => {
|
||||
const event = JSON.parse(String(raw));
|
||||
if (event.type === "auth") {
|
||||
assert.equal(event.token, "lite-agent-fixture-token");
|
||||
socket.send(JSON.stringify({ type: "ready", conversationId: "fixture-room" }));
|
||||
}
|
||||
});
|
||||
});
|
||||
const open = () => page.goto(new URL("/lite?agent=root", target).href, { waitUntil: "networkidle" });
|
||||
|
||||
try {
|
||||
await open();
|
||||
const composer = page.getByRole("textbox", { name: "Message Sybil Agent", exact: true });
|
||||
const send = page.getByRole("button", { name: "Send message", exact: true });
|
||||
await page.getByRole("status").filter({ hasText: /^Connected$/ }).waitFor();
|
||||
assert.equal(await page.getByRole("heading", { level: 1 }).count(), 1);
|
||||
assert.equal(await page.locator(".lite-agent img").count(), 0);
|
||||
assert.deepEqual(downloads, [], "attachments do not download automatically");
|
||||
assert.equal(requests.some(url => /character-|\/assets\/App-/.test(url)), false);
|
||||
assert.equal(requests.some(url => url.includes("never-fetch.invalid")), false);
|
||||
assert.equal(requests.some(url => url.includes("/presence/") || url.includes("/push/")), false, "lite does not start background push sessions");
|
||||
await composer.fill("A draft preserved on reload");
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await composer.waitFor();
|
||||
assert.equal(await composer.inputValue(), "A draft preserved on reload");
|
||||
await composer.fill("Durable message for offline retry");
|
||||
await send.click();
|
||||
await page.getByRole("button", { name: "Retry message", exact: true }).waitFor();
|
||||
assert.equal(await composer.inputValue(), "", "durable outbox owns a failed delivery after local save");
|
||||
assert.equal(submissions.length, 1);
|
||||
assert.match(submissions[0].requestId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
assert.equal(persistedBeforeSend[0][0].requestId, submissions[0].requestId, "retry identity saved before POST");
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByRole("button", { name: "Retry message", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll(".lite-agent .lite-error").length === 0);
|
||||
assert.equal(submissions.length, 2);
|
||||
assert.equal(submissions[1].requestId, submissions[0].requestId, "reload and retry keep one idempotent delivery identity");
|
||||
assert.equal(await page.locator(`[data-agent-message-id="inbound:sybil:${submissions[0].requestId}"]`).count(), 1);
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download fixture.png", exact: true }).click();
|
||||
assert.equal((await download).suggestedFilename(), "fixture.png");
|
||||
assert.equal(downloads.length, 1);
|
||||
assert.ok(downloads.every(url => !url.includes("token")));
|
||||
assert.equal(await page.locator(".lite-agent img").count(), 0, "explicit download does not add an animated image preview");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await composer.scrollIntoViewIfNeeded();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false);
|
||||
assert.equal(await page.getByText("Start a conversation with Sybil.", { exact: true }).count(), 0);
|
||||
const ownBubble = page.locator(`[data-agent-message-id="inbound:sybil:${submissions[0].requestId}"] .message-bubble--user`);
|
||||
assert.equal(await ownBubble.count(), 1, "Agent reuses the original user bubble");
|
||||
assert.equal(await ownBubble.evaluate(element => getComputedStyle(element).borderBottomRightRadius), "5px");
|
||||
const viewport = page.viewportSize();
|
||||
const composerBox = await composer.boundingBox();
|
||||
assert.ok(composerBox.y >= 0 && composerBox.y + composerBox.height <= viewport.height, "Agent composer stays in the viewport");
|
||||
const motion = await page.locator(".lite-agent").evaluate(element => [...element.querySelectorAll("*")].some(node => {
|
||||
const style = getComputedStyle(node);
|
||||
return style.animationName !== "none" || (style.transitionDuration !== "0s" && style.transitionDuration !== "0s, 0s");
|
||||
}));
|
||||
assert.equal(motion, false, "Agent presentation has no animations or transitions");
|
||||
if (process.env.LITE_AGENT_SCREENSHOT) await page.screenshot({ path: process.env.LITE_AGENT_SCREENSHOT, fullPage: true });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS Lite Agent: draft reload, durable failed send/retry, UUID fallback, explicit verified download, no media/push/animations, mobile layout");
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { marked } from "marked";
|
||||
import { installLiteCompatibility } from "../src/lib/lite-compat.ts";
|
||||
|
||||
test("Markdown still renders on readers without native Array.at", () => {
|
||||
const original = Object.getOwnPropertyDescriptor(Array.prototype, "at");
|
||||
try {
|
||||
delete Array.prototype.at;
|
||||
installLiteCompatibility();
|
||||
assert.equal([1, 2, 3].at(-1), 3);
|
||||
assert.equal([1, 2, 3].at(-4), undefined);
|
||||
assert.equal([1, 2, 3].at(Infinity), undefined);
|
||||
assert.equal(Object.getOwnPropertyDescriptor(Array.prototype, "at").enumerable, false);
|
||||
const html = marked("## A reply\n\nFirst paragraph.\n\nSecond paragraph.\n\n| Name | Value |\n| --- | --- |\n| One | Two |");
|
||||
assert.match(html, /<h2>A reply<\/h2>/);
|
||||
assert.match(html, /<table>/);
|
||||
assert.match(html, /<td>Two<\/td>/);
|
||||
} finally {
|
||||
if (original) Object.defineProperty(Array.prototype, "at", original);
|
||||
else delete Array.prototype.at;
|
||||
}
|
||||
});
|
||||
|
||||
test("Lite keeps the browser's existing Array.at implementation", () => {
|
||||
const original = Array.prototype.at;
|
||||
installLiteCompatibility();
|
||||
assert.equal(Array.prototype.at, original);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// All requests are fixtures. Run against a local production preview.
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
||||
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
|
||||
const date = "2026-11-11T12:00:00Z";
|
||||
const message = (id, content) => ({ id, role: "assistant", content, createdAt: date, name: null, metadata: null });
|
||||
const makeChat = (id, title) => ({ id, title, parentChatId: null, titleGenerationPending: false, createdAt: date, updatedAt: date,
|
||||
starred: false, starredAt: null, initiatedProvider: "openai", initiatedModel: "reader", lastUsedProvider: "openai", lastUsedModel: "reader",
|
||||
additionalSystemPrompt: null, enabledTools: [], messages: [message(`${id}-initial`, `${title} initial answer`)] });
|
||||
const waitGate = () => { let release; const promise = new Promise(resolve => { release = resolve; }); return { promise, release }; };
|
||||
async function waitUntil(check) {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!check()) {
|
||||
assert.ok(Date.now() < deadline, "fixture request arrived before timeout");
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture(mode) {
|
||||
const context = await browser.newContext({ viewport: { width: 1100, height: 850 } });
|
||||
const a = makeChat("a", "Conversation A"), b = makeChat("b", "Conversation B");
|
||||
const state = { active: mode.startsWith("attach"), attachCalls: 0, delayedReads: 0, submissions: 0, searchSubmissions: 0,
|
||||
collectionReads: 0, holdB: false, gate: waitGate(), errors: [] };
|
||||
const search = { id: "draft-search", title: "Draft search", query: "draft query", createdAt: date, updatedAt: date,
|
||||
starred: false, starredAt: null, requestId: null, latencyMs: null, error: null, answerText: "Draft search completed",
|
||||
answerRequestId: null, answerCitations: null, answerError: null, results: [] };
|
||||
await context.addInitScript(() => localStorage.setItem("sybil_admin_token", "reader-token"));
|
||||
await context.route("**/*", async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== target.origin) return route.abort();
|
||||
if (!url.pathname.startsWith("/api/")) return route.continue();
|
||||
const path = url.pathname.slice(4);
|
||||
let body;
|
||||
if (path === "/v1/auth/session") body = { authenticated: true, mode: "token" };
|
||||
else if (path === "/v1/workspace-items") {
|
||||
state.collectionReads += 1;
|
||||
if (mode === "draft-navigation" && state.collectionReads === 1) await state.gate.promise;
|
||||
body = { items: [a, b].map(chat => ({ ...chat, type: "chat" })) };
|
||||
}
|
||||
else if (path === "/v1/models") body = { providers: { openai: { models: ["reader"], loadedAt: date, error: null } } };
|
||||
else if (path === "/v1/active-runs") body = { chats: state.active ? ["a"] : [], searches: [] };
|
||||
else if (path === "/v1/chats/a") body = { chat: a };
|
||||
else if (path === "/v1/chats/b") {
|
||||
if (state.holdB) { state.delayedReads += 1; await state.gate.promise; }
|
||||
body = { chat: b };
|
||||
}
|
||||
else if (path === "/v1/chat-completions/stream") {
|
||||
state.submissions += 1;
|
||||
await state.gate.promise;
|
||||
b.messages.push(message("late-send", "Late reply for conversation B"));
|
||||
return route.fulfill({ contentType: "text/event-stream", body: 'event: done\ndata: {"text":"Late reply for conversation B"}\n\n' }).catch(() => undefined);
|
||||
}
|
||||
else if (path === "/v1/searches" || path === "/v1/searches/draft-search") body = { search };
|
||||
else if (path === "/v1/searches/draft-search/run/stream") {
|
||||
state.searchSubmissions += 1;
|
||||
return route.fulfill({ contentType: "text/event-stream", body: `event: done\ndata: ${JSON.stringify({ search })}\n\n` });
|
||||
}
|
||||
else if (path === "/v1/chats/a/stream/attach") {
|
||||
state.attachCalls += 1;
|
||||
if (mode === "attach-race") {
|
||||
state.active = false; a.messages.push(message("finished", "Completed before attachment"));
|
||||
return route.fulfill({ status: 404, json: { message: "active chat stream not found" } });
|
||||
}
|
||||
if (mode === "attach-retry" && state.attachCalls === 1) {
|
||||
return route.fulfill({ status: 503, json: { message: "Connection is temporarily unavailable" } });
|
||||
}
|
||||
await state.gate.promise;
|
||||
state.active = false; a.messages.push(message("finished", "Recovered completed answer"));
|
||||
return route.fulfill({ contentType: "text/event-stream", body: 'event: done\ndata: {"text":"Recovered completed answer"}\n\n' });
|
||||
}
|
||||
else if (/\/agent\//.test(path)) body = {};
|
||||
else return route.fulfill({ status: 404, json: { message: `Unexpected fixture route ${path}` } });
|
||||
return route.fulfill({ json: body }).catch(() => undefined);
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on("pageerror", error => state.errors.push(error.message));
|
||||
await page.goto(new URL(mode === "draft-navigation" ? "/lite" : "/lite?chat=a", target).href);
|
||||
await page.getByRole("heading", { name: mode === "draft-navigation" ? "New chat" : "Conversation A", exact: true }).waitFor();
|
||||
return { context, page, state, a, b };
|
||||
}
|
||||
|
||||
try {
|
||||
{
|
||||
const { context, page, state } = await fixture("attach-success");
|
||||
await page.waitForFunction(() => document.querySelector("#lite-draft")?.disabled === true);
|
||||
await waitUntil(() => state.attachCalls === 1);
|
||||
assert.equal(state.attachCalls, 1);
|
||||
assert.equal(await page.getByRole("button", { name: "Send message", exact: true }).isDisabled(), true);
|
||||
assert.equal(await page.getByText("Recovered completed answer", { exact: true }).count(), 0);
|
||||
state.gate.release();
|
||||
await page.getByText("Recovered completed answer", { exact: true }).waitFor();
|
||||
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
|
||||
assert.deepEqual(state.errors, []);
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const { context, page, state } = await fixture("attach-race");
|
||||
await page.getByText("Completed before attachment", { exact: true }).waitFor();
|
||||
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
|
||||
assert.equal(await page.getByRole("alert").count(), 0, "finished attachment race recovers quietly");
|
||||
assert.deepEqual(state.errors, []);
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const { context, page, state } = await fixture("attach-retry");
|
||||
await page.getByRole("alert").filter({ hasText: "Connection is temporarily unavailable" }).waitFor();
|
||||
assert.equal(await page.getByRole("button", { name: "Refresh", exact: true }).isEnabled(), true, "remote work cannot disable recovery");
|
||||
assert.equal(await page.getByRole("button", { name: "New chat", exact: true }).isEnabled(), true, "failed attachment cannot trap navigation");
|
||||
state.gate.release();
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await page.getByText("Recovered completed answer", { exact: true }).waitFor();
|
||||
assert.equal(state.attachCalls, 2);
|
||||
assert.deepEqual(state.errors, []);
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const { context, page, state } = await fixture("navigation");
|
||||
await page.getByText("Conversation A initial answer", { exact: true }).waitFor();
|
||||
state.holdB = true;
|
||||
await page.locator(".lite-history-list button").filter({ hasText: "Conversation B" }).click();
|
||||
await page.waitForURL(/chat=b/);
|
||||
await page.waitForFunction(() => document.querySelector(".lite-status")?.textContent === "Loading…");
|
||||
await page.locator(".lite-history-list button").filter({ hasText: "Conversation A" }).click();
|
||||
await page.getByText("Conversation A initial answer", { exact: true }).waitFor();
|
||||
state.gate.release();
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(await page.getByText("Conversation B initial answer", { exact: true }).count(), 0, "late selection cannot overwrite the current transcript");
|
||||
assert.equal(new URL(page.url()).searchParams.get("chat"), "a");
|
||||
await page.goBack();
|
||||
await page.getByText("Conversation B initial answer", { exact: true }).waitFor();
|
||||
assert.equal(new URL(page.url()).searchParams.get("chat"), "b");
|
||||
assert.deepEqual(state.errors, []);
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const { context, page, state } = await fixture("logout");
|
||||
state.holdB = true;
|
||||
await page.locator(".lite-history-list button").filter({ hasText: "Conversation B" }).click();
|
||||
await page.waitForURL(/chat=b/);
|
||||
await page.getByRole("button", { name: "Sign out", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "Sign in to Sybil", exact: true }).waitFor();
|
||||
state.gate.release();
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(await page.getByText("Conversation B initial answer", { exact: true }).count(), 0, "late authenticated reads cannot repaint after sign out");
|
||||
assert.deepEqual(state.errors, []);
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const { context, page, state } = await fixture("back-during-send");
|
||||
await page.locator(".lite-history-list button").filter({ hasText: "Conversation B" }).click();
|
||||
await page.getByText("Conversation B initial answer", { exact: true }).waitFor();
|
||||
await page.getByLabel("Message", { exact: true }).fill("A question only for conversation B");
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector("#lite-draft")?.disabled === true);
|
||||
await waitUntil(() => state.submissions === 1);
|
||||
await page.goBack();
|
||||
await page.getByText("Conversation A initial answer", { exact: true }).waitFor();
|
||||
state.gate.release();
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(await page.getByLabel("Message", { exact: true }).inputValue(), "", "aborted send must not transplant a draft into another conversation");
|
||||
assert.equal(await page.getByText("Late reply for conversation B", { exact: true }).count(), 0);
|
||||
assert.equal(await page.getByText("A question only for conversation B", { exact: true }).count(), 0);
|
||||
assert.deepEqual(state.errors, []);
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const { context, page, state, a } = await fixture("model-refresh");
|
||||
await page.getByText("Conversation A initial answer", { exact: true }).waitFor();
|
||||
await page.locator("summary").filter({ hasText: "Settings" }).click();
|
||||
await page.getByLabel("Model", { exact: true }).fill("my-next-model");
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
|
||||
assert.equal(await page.getByLabel("Model", { exact: true }).inputValue(), "my-next-model", "refresh preserves an unsent model choice");
|
||||
a.lastUsedModel = "updated-on-another-device";
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector('input[list="lite-model-options"]').value === "updated-on-another-device");
|
||||
await page.locator(".lite-history-list button").filter({ hasText: "Conversation B" }).click();
|
||||
await page.getByText("Conversation B initial answer", { exact: true }).waitFor();
|
||||
assert.equal(await page.getByLabel("Model", { exact: true }).inputValue(), "reader", "switching conversations restores its saved model");
|
||||
assert.deepEqual(state.errors, []);
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const { context, page, state } = await fixture("draft-navigation");
|
||||
await waitUntil(() => state.collectionReads === 1);
|
||||
assert.equal(await page.getByLabel("Message", { exact: true }).isDisabled(), true);
|
||||
await page.getByRole("button", { name: "New search", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "New search", exact: true }).waitFor();
|
||||
await waitUntil(() => state.collectionReads >= 2);
|
||||
await page.getByRole("button", { name: "New search", exact: true }).click();
|
||||
state.gate.release();
|
||||
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
|
||||
await page.getByLabel("Search the web", { exact: true }).fill("draft query");
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
await page.getByText("Draft search completed", { exact: true }).waitFor();
|
||||
assert.equal(state.searchSubmissions, 1);
|
||||
assert.deepEqual(state.errors, []);
|
||||
await context.close();
|
||||
}
|
||||
console.log("Lite recovery browser checks passed: active-stream attachment, completion race, retry, stale navigation, back, sign-out isolation, model selection and repeated draft navigation.");
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,194 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
// Run against npm run build && npm run preview; all API responses are local fixtures.
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
||||
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
|
||||
const context = await browser.newContext({ viewport: { width: 1100, height: 900 } });
|
||||
const date = "2026-11-11T12:00:00.000Z";
|
||||
const markdown = "A response with **formatted text**.\n\n- First item\n- Second item\n\n| Name | Value |\n| --- | --- |\n| Example | Table content |\n\n```js\nconst longLine = '" + "wide-content-".repeat(35) + "';\n```\n\n";
|
||||
const message = (id, role, content, metadata = null) => ({ id, role, content, createdAt: date, name: null, metadata });
|
||||
const chat = { id: "chat-one", title: "Reader test conversation", parentChatId: null, titleGenerationPending: false,
|
||||
createdAt: date, updatedAt: date, starred: true, starredAt: date, initiatedProvider: "openai", initiatedModel: "reader-model",
|
||||
lastUsedProvider: "openai", lastUsedModel: "reader-model", additionalSystemPrompt: "Keep replies concise", enabledTools: [],
|
||||
messages: [message("user-one", "user", "Show a list, a table, and a code sample."), message("assistant-one", "assistant", markdown, { generation: { provider: "openai", model: "reader-model" } })] };
|
||||
const chats = new Map([[chat.id, chat]]);
|
||||
const searches = new Map();
|
||||
const calls = [], errors = [], requests = [], streamRequests = [];
|
||||
let failFirstStream = true, failNextStream = false;
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem("sybil_admin_token", "lite-fixture-token");
|
||||
// Chromium readers can predate this built-in even though they support modules.
|
||||
delete Array.prototype.at;
|
||||
});
|
||||
await context.route("**/*", async route => {
|
||||
const url = new URL(route.request().url());
|
||||
requests.push(url.href);
|
||||
if (url.origin !== target.origin) return route.abort();
|
||||
if (!url.pathname.startsWith("/api/")) return route.continue();
|
||||
const path = url.pathname.slice(4), method = route.request().method();
|
||||
calls.push({ path, method });
|
||||
let body;
|
||||
if (path === "/v1/auth/session") body = { mode: "token", authenticated: true };
|
||||
else if (path === "/v1/workspace-items") {
|
||||
const items = [...chats.values()].map(chat => ({ ...chat, type: "chat" })).concat([...searches.values()].map(search => ({ ...search, type: "search" })));
|
||||
body = { items: url.searchParams.has("q") ? items.filter(item => item.title.includes(url.searchParams.get("q"))) : items, hasMore: false };
|
||||
}
|
||||
else if (path === "/v1/models") body = { providers: { openai: { models: ["reader-model", "another-model"], loadedAt: date, error: null } } };
|
||||
else if (path === "/v1/active-runs") body = { chats: [], searches: [] };
|
||||
else if (path === "/v1/chats" && method === "POST") {
|
||||
const created = { ...chat, id: `chat-${chats.size + 1}`, title: "New conversation", messages: [], starred: false, additionalSystemPrompt: null };
|
||||
chats.set(created.id, created); body = { chat: created };
|
||||
}
|
||||
else if (/^\/v1\/chats\/[^/]+$/.test(path)) body = { chat: chats.get(path.split("/").pop()) };
|
||||
else if (path.includes("/title")) body = { chat };
|
||||
else if (path === "/v1/chat-completions/stream") {
|
||||
const request = route.request().postDataJSON(); streamRequests.push(request);
|
||||
if (failFirstStream) { failFirstStream = false; return route.abort("failed"); }
|
||||
if (failNextStream) { failNextStream = false; return route.fulfill({ status: 503, json: { message: "Model is temporarily unavailable" } }); }
|
||||
assert.ok(request.clientRequestId);
|
||||
const selected = chats.get(request.chatId);
|
||||
const content = request.messages[request.messages.length - 1].content;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
selected.messages.push(message(`user-${streamRequests.length}`, "user", content), message(`assistant-${streamRequests.length}`, "assistant", "The completed answer.", { generation: { provider: request.provider, model: request.model } }));
|
||||
return route.fulfill({ contentType: "text/event-stream", body: 'event: delta\ndata: {"text":"Do not paint partial tokens"}\n\nevent: done\ndata: {"text":"The completed answer."}\n\n' });
|
||||
}
|
||||
else if (path === "/v1/searches" && method === "POST") {
|
||||
const request = route.request().postDataJSON();
|
||||
const search = { id: "search-one", title: request.title, query: request.query, createdAt: date, updatedAt: date, starred: false, starredAt: null,
|
||||
requestId: null, latencyMs: null, error: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [] };
|
||||
searches.set(search.id, search); body = { search };
|
||||
}
|
||||
else if (path === "/v1/searches/search-one/run/stream") {
|
||||
const search = searches.get("search-one"); search.answerText = "A useful search answer.";
|
||||
search.results = [{ id: "result", title: "An original source", url: "https://example.test/source", text: "Source description", highlights: [], rank: 1 }];
|
||||
return route.fulfill({ contentType: "text/event-stream", body: `event: done\ndata: ${JSON.stringify({ search })}\n\n` });
|
||||
}
|
||||
else if (path === "/v1/searches/search-one") body = { search: searches.get("search-one") };
|
||||
else return route.fulfill({ status: 404, json: { message: `Unexpected fixture request ${method} ${path}` } });
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
page.setDefaultTimeout(10000);
|
||||
async function openSidebar() {
|
||||
const trigger = page.getByRole("button", { name: "Open sidebar", exact: true });
|
||||
if (await trigger.isVisible()) await trigger.click();
|
||||
}
|
||||
async function selectNavigation(name) {
|
||||
await openSidebar();
|
||||
await page.getByRole("button", { name, exact: true }).click();
|
||||
}
|
||||
async function checkComposerLayout(width) {
|
||||
const scroll = page.locator(".lite-scroll");
|
||||
const before = await page.locator(".lite-composer-shell").boundingBox();
|
||||
assert.ok(before.y >= 0 && before.y + before.height <= page.viewportSize().height + 1, `composer visible at ${width}px`);
|
||||
await scroll.evaluate(element => { element.scrollTop = element.scrollHeight; });
|
||||
const after = await page.locator(".lite-composer-shell").boundingBox();
|
||||
assert.equal(after.y, before.y, "scrolling the transcript does not move the composer");
|
||||
await scroll.evaluate(element => { element.scrollTop = 0; });
|
||||
const pageHeight = await page.evaluate(() => ({ height: document.documentElement.scrollHeight, viewport: window.innerHeight }));
|
||||
assert.ok(pageHeight.height <= pageHeight.viewport + 1, `transcript content stays inside its scroller at ${width}px: ${JSON.stringify(pageHeight)}`);
|
||||
}
|
||||
try {
|
||||
await page.goto(new URL("/lite?chat=chat-one", target).href);
|
||||
await page.getByRole("heading", { name: chat.title, exact: true }).waitFor();
|
||||
await page.getByText("First item", { exact: true }).waitFor();
|
||||
assert.equal(await page.getByLabel("Model", { exact: true }).inputValue(), "reader-model");
|
||||
assert.equal(await page.locator(".lite-main img").count(), 0);
|
||||
assert.equal(await page.locator(".md-content table").count(), 1);
|
||||
const visuals = await page.evaluate(() => ({
|
||||
animated: [...document.querySelectorAll("*")].filter(el => getComputedStyle(el).animationName !== "none").length,
|
||||
overflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
background: getComputedStyle(document.documentElement).backgroundImage,
|
||||
}));
|
||||
assert.equal(visuals.animated, 0); assert.equal(visuals.overflow, false); assert.equal(visuals.background, "none");
|
||||
for (const text of ["What’s on your mind?", "Made for slower screens.", "A little space to think", "Your persistent assistant", "Find something useful", "What would you like to find?", "Clear text.", "Fewer distractions.", "Simple pages.", "Replies appear when complete."]) {
|
||||
assert.equal(await page.getByText(text, { exact: false }).count(), 0, `no added flavor text: ${text}`);
|
||||
}
|
||||
const bubble = await page.locator(".lite-message-user .message-bubble--user").first().evaluate(element => {
|
||||
const style = getComputedStyle(element), box = element.getBoundingClientRect(), row = element.closest(".lite-message").getBoundingClientRect();
|
||||
return { right: box.right, rowRight: row.right, width: box.width, rowWidth: row.width, bottomRightRadius: style.borderBottomRightRadius, background: style.backgroundImage };
|
||||
});
|
||||
assert.ok(Math.abs(bubble.right - bubble.rowRight) <= 1 && bubble.width < bubble.rowWidth, "user bubbles retain original right alignment and width limit");
|
||||
assert.equal(bubble.bottomRightRadius, "5px");
|
||||
assert.equal(bubble.background, "none");
|
||||
assert.equal(await page.locator('[data-assistant-footer="assistant-one"]').getByRole("button", { name: "Copy message", exact: true }).count(), 1);
|
||||
await checkComposerLayout(1100);
|
||||
if (process.env.LITE_ARTIFACT_DIR) {
|
||||
await mkdir(process.env.LITE_ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: `${process.env.LITE_ARTIFACT_DIR}/lite-desktop.png`, fullPage: true });
|
||||
}
|
||||
// Selecting the current row must retain its loaded transcript.
|
||||
await page.locator(".lite-history-list button").filter({ hasText: chat.title }).click();
|
||||
await page.getByText("First item", { exact: true }).waitFor();
|
||||
await page.getByLabel("Message", { exact: true }).fill("A follow-up question");
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
await page.getByText("The completed answer.", { exact: true }).waitFor();
|
||||
assert.equal(await page.getByLabel("Message", { exact: true }).inputValue(), "");
|
||||
assert.equal(await page.getByText("Do not paint partial tokens", { exact: true }).count(), 0);
|
||||
assert.equal(streamRequests.length, 2);
|
||||
assert.equal(streamRequests[0].clientRequestId, streamRequests[1].clientRequestId, "transport retry reuses submission ID");
|
||||
assert.deepEqual(streamRequests[0].messages, streamRequests[1].messages);
|
||||
|
||||
failNextStream = true;
|
||||
await page.getByLabel("Message", { exact: true }).fill("Preserve this draft");
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
await page.getByRole("alert").filter({ hasText: "Model is temporarily unavailable" }).waitFor();
|
||||
assert.equal(await page.getByLabel("Message", { exact: true }).inputValue(), "Preserve this draft");
|
||||
const failedRequest = streamRequests[streamRequests.length - 1];
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector("#lite-draft").value === "");
|
||||
assert.equal(streamRequests[streamRequests.length - 1].clientRequestId, failedRequest.clientRequestId);
|
||||
|
||||
for (const width of [320, 390, 600]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false, `no overflow at ${width}px`);
|
||||
await checkComposerLayout(width);
|
||||
await openSidebar();
|
||||
await page.getByLabel("Search history", { exact: true }).fill("Reader");
|
||||
const row = page.locator(".lite-history-list button").filter({ hasText: chat.title });
|
||||
await row.waitFor();
|
||||
await row.click();
|
||||
assert.equal(await page.getByRole("button", { name: "Open sidebar", exact: true }).getAttribute("aria-expanded"), "false");
|
||||
assert.equal(await page.locator(".lite-sidebar").evaluate(element => getComputedStyle(element).transitionDuration), "0s");
|
||||
if (process.env.LITE_ARTIFACT_DIR) await page.screenshot({ path: `${process.env.LITE_ARTIFACT_DIR}/lite-${width}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
await selectNavigation("New chat");
|
||||
await page.getByRole("heading", { name: "New chat", exact: true }).waitFor();
|
||||
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
|
||||
assert.equal(await page.locator(".lite-scroll").textContent(), "", "new chats have an empty transcript without welcome copy");
|
||||
await page.getByLabel("Message", { exact: true }).fill("An unsent draft");
|
||||
await selectNavigation("New chat");
|
||||
assert.equal(await page.getByLabel("Message", { exact: true }).inputValue(), "");
|
||||
await page.getByLabel("Message", { exact: true }).fill("Start a new conversation");
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
await page.getByText("The completed answer.", { exact: true }).waitFor();
|
||||
assert.ok(new URL(page.url()).searchParams.get("chat"));
|
||||
await selectNavigation("New search");
|
||||
await page.getByLabel("Search the web", { exact: true }).fill("e-ink reading");
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
await page.getByText("A useful search answer.", { exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole("link", { name: "An original source" }).getAttribute("href"), "https://example.test/source");
|
||||
assert.equal(new URL(page.url()).searchParams.get("search"), "search-one");
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(requests.some(url => /character-|\/assets\/App-/.test(url)), false);
|
||||
assert.equal(await page.evaluate(() => Array.from(document.styleSheets).filter(sheet => !sheet.href || new URL(sheet.href).origin === location.origin)
|
||||
.some(sheet => Array.from(sheet.cssRules).some(rule => rule.cssText.includes(".tool-call-stack-shell")))), false, "full animation styles are not loaded");
|
||||
assert.equal(calls.some(call => /\/agent\//.test(call.path)), false, "chat/search does not open Agent or presence polling");
|
||||
console.log("Lite workspace browser checks passed: original bubbles/footer, no flavor text, fixed composer, chat/retry, history/search, mobile sidebar and isolated assets.");
|
||||
} catch (error) {
|
||||
console.error("Workspace fixture state", { ...(await page.evaluate(() => ({
|
||||
url: location.href, draft: document.querySelector("#lite-draft")?.value,
|
||||
disabled: document.querySelector("#lite-draft")?.disabled,
|
||||
title: document.querySelector("main h1")?.textContent,
|
||||
statuses: [...document.querySelectorAll('[role="status"]')].map(element => element.textContent),
|
||||
alerts: [...document.querySelectorAll('[role="alert"]')].map(element => element.textContent),
|
||||
}))), pageErrors: errors, streams: streamRequests.length, recentCalls: calls.slice(-12) });
|
||||
throw error;
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Run against a production preview; requests to the backend are mocked.
|
||||
// WEB_TEST_URL=http://127.0.0.1:5197 PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs
|
||||
// CHROME_EXECUTABLE=/path/to/chrome node tests/ui-mode.browser.mjs
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
||||
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.CHROME_EXECUTABLE || undefined,
|
||||
headless: true,
|
||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}),
|
||||
});
|
||||
const kindle = "Mozilla/5.0 (X11; Linux armv7l) AppleWebKit/537.36 Chrome/87.0 Safari/537.36 Kindle/3.0";
|
||||
|
||||
async function fixture(options = {}) {
|
||||
const context = await browser.newContext({ viewport: { width: 600, height: 800 }, serviceWorkers: "block", ...options });
|
||||
const requests = [], errors = [];
|
||||
await context.route("**/*", async route => {
|
||||
const url = new URL(route.request().url());
|
||||
requests.push(url);
|
||||
if (url.origin !== target.origin) return route.abort();
|
||||
if (url.pathname.endsWith("/v1/auth/session")) {
|
||||
return route.fulfill({ status: 401, json: { message: "missing bearer token" } });
|
||||
}
|
||||
if (url.pathname.includes("/api/")) return route.fulfill({ json: {} });
|
||||
return route.continue();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
const visit = async path => {
|
||||
requests.length = 0;
|
||||
await Promise.all([
|
||||
page.waitForResponse(response => response.url().endsWith("/v1/auth/session")),
|
||||
page.goto(new URL(path, target).href, { waitUntil: "networkidle" }),
|
||||
]);
|
||||
await page.locator('input[type="password"]').waitFor();
|
||||
};
|
||||
return { context, page, requests, errors, visit };
|
||||
}
|
||||
|
||||
try {
|
||||
const reader = await fixture({ userAgent: kindle });
|
||||
await reader.visit("/?chat=keep-this#reply");
|
||||
assert.equal(new URL(reader.page.url()).pathname, "/lite");
|
||||
assert.equal(new URL(reader.page.url()).search, "?chat=keep-this");
|
||||
assert.equal(new URL(reader.page.url()).hash, "#reply");
|
||||
const styles = await reader.page.evaluate(() => Array.from(document.styleSheets).map(sheet => sheet.href));
|
||||
assert.ok(styles.some(href => href && /lite.*\.css/.test(href)), "Lite has its own stylesheet");
|
||||
assert.ok(reader.requests.every(url => !/character-.*\.(gif|png)|\/App-[^/]+\.js|agent-conversation/.test(url.href)), "Lite does not download full presentation assets");
|
||||
assert.equal(await reader.page.locator(".lite-auth").evaluate(element => getComputedStyle(element).backgroundImage), "none");
|
||||
const liteTypography = await reader.page.evaluate(() => ({
|
||||
body: getComputedStyle(document.body).fontFamily,
|
||||
wordmark: getComputedStyle(document.querySelector(".sybil-wordmark")).fontFamily,
|
||||
}));
|
||||
assert.ok(reader.requests.some(url => url.hostname === "fonts.googleapis.com"), "both presentations request the existing font stylesheet");
|
||||
for (const text of ["A quieter workspace", "Simple pages.", "Clear text.", "No animations."]) {
|
||||
assert.equal(await reader.page.getByText(text, { exact: false }).count(), 0, `no added sign-in flavor text: ${text}`);
|
||||
}
|
||||
assert.equal(await reader.page.locator('meta[name="viewport"]').getAttribute("content"), "width=device-width, initial-scale=1, viewport-fit=cover");
|
||||
|
||||
await reader.visit("/?ui=full&chat=keep-this");
|
||||
assert.equal(new URL(reader.page.url()).pathname, "/");
|
||||
assert.deepEqual(await reader.page.evaluate(() => ({
|
||||
body: getComputedStyle(document.body).fontFamily,
|
||||
wordmark: getComputedStyle(document.querySelector(".sybil-wordmark")).fontFamily,
|
||||
})), liteTypography, "Lite preserves the original body and wordmark typefaces");
|
||||
assert.equal(await reader.page.evaluate(() => localStorage.getItem("sybil:ui-mode")), "full");
|
||||
await reader.visit("/");
|
||||
assert.equal(new URL(reader.page.url()).pathname, "/", "stored full choice wins over Kindle hint");
|
||||
await reader.visit("/lite?ui=lite");
|
||||
await reader.visit("/");
|
||||
assert.equal(new URL(reader.page.url()).pathname, "/lite", "explicit Lite choice persists");
|
||||
assert.deepEqual(reader.errors, []);
|
||||
await reader.context.close();
|
||||
|
||||
const dataSaver = await fixture();
|
||||
await dataSaver.context.addInitScript(() => Object.defineProperty(navigator, "connection", { value: { saveData: true } }));
|
||||
await dataSaver.visit("/");
|
||||
assert.equal(new URL(dataSaver.page.url()).pathname, "/lite");
|
||||
assert.deepEqual(dataSaver.errors, []);
|
||||
await dataSaver.context.close();
|
||||
|
||||
const blocked = await fixture();
|
||||
await blocked.context.addInitScript(() => Object.defineProperty(window, "localStorage", { get() { throw new Error("Storage denied"); } }));
|
||||
await blocked.visit("/?ui=lite");
|
||||
assert.equal(new URL(blocked.page.url()).pathname, "/lite");
|
||||
assert.deepEqual(blocked.errors, [], "denied storage does not prevent sign in");
|
||||
await blocked.context.close();
|
||||
|
||||
const failed = await fixture();
|
||||
await failed.context.route("**/assets/lite-route-page-*.js", route => route.abort());
|
||||
await failed.page.goto(new URL("/lite", target).href);
|
||||
await failed.page.getByRole("heading", { name: "Sybil could not load" }).waitFor();
|
||||
assert.equal(await failed.page.getByRole("button", { name: "Reload" }).count(), 1);
|
||||
await failed.context.close();
|
||||
console.log("UI routing, preference persistence, denied storage, isolated Lite assets and route failure recovery passed.");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
UI_MODE_STORAGE_KEY,
|
||||
buildUiModeUrl,
|
||||
persistUiModePreference,
|
||||
prefersLiteUi,
|
||||
readUiModePreference,
|
||||
resolveUiRoute,
|
||||
} from "../src/lib/ui-mode.ts";
|
||||
|
||||
const location = (path = "/") => new URL(path, "https://sybil.test");
|
||||
const reader = { userAgent: "Mozilla/5.0 (X11; Linux armv7l) AppleWebKit/531.2 Kindle/3.0" };
|
||||
|
||||
test("clear reader tokens and explicit data saving select Lite without treating all mobile devices as readers", () => {
|
||||
assert.equal(prefersLiteUi(reader), true);
|
||||
assert.equal(prefersLiteUi({ userAgent: "Kobo Touch" }), true);
|
||||
assert.equal(prefersLiteUi({ userAgent: "PocketBook/1.0" }), true);
|
||||
assert.equal(prefersLiteUi({ userAgent: "Mozilla/5.0 Silk/44.1 like Chrome/44.0" }), false);
|
||||
assert.equal(prefersLiteUi({ userAgent: "Mozilla/5.0 Kindle Fire Build/ABC" }), false);
|
||||
assert.equal(prefersLiteUi({ userAgent: "Mozilla/5.0 iPhone Mobile Safari" }), false);
|
||||
assert.equal(prefersLiteUi({ userAgent: "Mozilla/5.0 Android Mobile Chrome/140" }), false);
|
||||
assert.equal(prefersLiteUi({ saveData: true }), true);
|
||||
assert.equal(prefersLiteUi({}), false);
|
||||
});
|
||||
|
||||
test("automatic routing preserves deep links and hashes and only applies at the workspace entry", () => {
|
||||
assert.deepEqual(resolveUiRoute(location("/?chat=abc&extra=value#reply"), reader, null), {
|
||||
mode: "lite", url: "/lite?chat=abc&extra=value#reply", preference: null,
|
||||
});
|
||||
assert.equal(resolveUiRoute(location("/?agent=root"), reader, null).url, "/lite?agent=root");
|
||||
assert.equal(resolveUiRoute(location("/search?q=eink"), reader, "lite").url, "/search?q=eink");
|
||||
assert.equal(resolveUiRoute(location("/other"), reader, "lite").url, "/other");
|
||||
});
|
||||
|
||||
test("explicit preference wins over detection and storage; a direct Lite visit wins over stored full", () => {
|
||||
assert.deepEqual(resolveUiRoute(location("/?ui=full&chat=a"), reader, "lite"), {
|
||||
mode: "full", url: "/?ui=full&chat=a", preference: "full",
|
||||
});
|
||||
assert.equal(resolveUiRoute(location("/?ui=lite"), {}, "full").url, "/lite?ui=lite");
|
||||
assert.equal(resolveUiRoute(location("/lite?ui=full&chat=a"), reader, "lite").url, "/?ui=full&chat=a");
|
||||
assert.equal(resolveUiRoute(location("/lite/"), {}, "full").url, "/lite");
|
||||
assert.equal(resolveUiRoute(location("/"), {}, "lite").url, "/lite");
|
||||
assert.equal(resolveUiRoute(location("/"), reader, "full").url, "/");
|
||||
assert.equal(resolveUiRoute(location("/?ui=invalid"), {}, null).url, "/?ui=invalid");
|
||||
});
|
||||
|
||||
test("manual mode links preserve workspace selection, standalone query and hash", () => {
|
||||
assert.equal(buildUiModeUrl("lite", location("/?chat=abc&ui=full#reply")), "/lite?chat=abc&ui=lite#reply");
|
||||
assert.equal(buildUiModeUrl("full", location("/lite?search=abc&ui=lite")), "/?search=abc&ui=full");
|
||||
assert.equal(buildUiModeUrl("lite", location("/search?q=black+tea")), "/lite?q=black+tea&ui=lite");
|
||||
assert.equal(buildUiModeUrl("full", location("/lite?q=black+tea")), "/search?q=black+tea&ui=full");
|
||||
assert.equal(buildUiModeUrl("full", location("/lite?q=old&chat=current")), "/?q=old&chat=current&ui=full");
|
||||
assert.equal(resolveUiRoute(location("/lite?q=black+tea&ui=full"), {}, null).url, "/search?q=black+tea&ui=full");
|
||||
});
|
||||
|
||||
test("mode preference survives visits but denied or malformed storage cannot block startup", () => {
|
||||
const values = new Map();
|
||||
const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) };
|
||||
assert.equal(readUiModePreference(storage), null);
|
||||
persistUiModePreference(storage, "lite");
|
||||
assert.equal(readUiModePreference(storage), "lite");
|
||||
values.set(UI_MODE_STORAGE_KEY, "unknown");
|
||||
assert.equal(readUiModePreference(storage), null);
|
||||
const blocked = { getItem() { throw new Error("denied"); }, setItem() { throw new Error("denied"); } };
|
||||
assert.equal(readUiModePreference(blocked), null);
|
||||
assert.doesNotThrow(() => persistUiModePreference(blocked, "full"));
|
||||
assert.equal(readUiModePreference(null), null);
|
||||
assert.doesNotThrow(() => persistUiModePreference(null, "lite"));
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildCompletionMessages, buildWorkspaceUrl, getModelOptions, getVisibleProviders,
|
||||
hasNewPersistedUserMessage, loadStoredModelPreferences, normalizeStoredModelPreferences,
|
||||
pickProviderModel, readSidebarSelectionFromUrl, saveModelPreferences,
|
||||
} from "../src/lib/workspace-domain.ts";
|
||||
|
||||
const attachment = { kind: "text", id: "notes", filename: "notes.txt", mimeType: "text/plain", sizeBytes: 4, text: "plan", truncated: false };
|
||||
const message = (id, role, content, metadata = null) => ({ id, role, content, metadata, createdAt: "2026-01-01T00:00:00Z", name: null });
|
||||
|
||||
test("both presentations submit provider history without UI tool logs and preserve attachments", () => {
|
||||
const history = [
|
||||
message("s", "system", "Be concise"),
|
||||
message("u", "user", "Read my notes", { attachments: [attachment] }),
|
||||
message("log", "tool", "Searching the web", { kind: "tool_call", toolCallId: "call-1" }),
|
||||
{ ...message("tool", "tool", "Provider tool result"), name: "web_search" },
|
||||
message("a", "assistant", "Here is the plan"),
|
||||
];
|
||||
const payload = buildCompletionMessages(history, "Continue", [attachment]);
|
||||
assert.deepEqual(payload.map(entry => entry.role), ["system", "user", "tool", "assistant", "user"]);
|
||||
assert.ok(!payload.some(entry => entry.id === "log"));
|
||||
assert.deepEqual(payload[1].attachments, [attachment]);
|
||||
assert.equal(payload[2].name, "web_search");
|
||||
assert.deepEqual(payload.at(-1), { role: "user", content: "Continue", attachments: [attachment] });
|
||||
assert.equal(history.length, 5);
|
||||
});
|
||||
|
||||
test("reconciliation recognizes only a new matching persisted user turn", () => {
|
||||
const old = message("old", "user", "Continue");
|
||||
const next = message("next", "user", "Continue", { attachments: [attachment] });
|
||||
const chat = { messages: [old, next] };
|
||||
assert.equal(hasNewPersistedUserMessage(chat, new Set(["old"]), "Continue", [attachment]), true);
|
||||
assert.equal(hasNewPersistedUserMessage(chat, new Set(["old", "next"]), "Continue", [attachment]), false);
|
||||
assert.equal(hasNewPersistedUserMessage(chat, new Set(["old"]), "Continue", []), false);
|
||||
assert.equal(hasNewPersistedUserMessage(chat, new Set(["old"]), "Different", [attachment]), false);
|
||||
});
|
||||
|
||||
test("model policy preserves custom selections and uses advertised provider models", () => {
|
||||
const catalog = { openai: { models: ["server-model"] }, "hermes-agent": { models: ["hermes"] } };
|
||||
assert.deepEqual(getModelOptions(catalog, "openai"), ["server-model"]);
|
||||
assert.equal(pickProviderModel(["server-model"], " custom-model "), "custom-model");
|
||||
assert.equal(pickProviderModel(["server-model"], null), "server-model");
|
||||
assert.ok(getVisibleProviders(catalog).includes("hermes-agent"));
|
||||
assert.ok(!getVisibleProviders({}).includes("hermes-agent"));
|
||||
assert.equal(normalizeStoredModelPreferences({ openai: " x ", xai: 123 }).openai, "x");
|
||||
assert.equal(normalizeStoredModelPreferences({ xai: 123 }).xai, null);
|
||||
});
|
||||
|
||||
test("selection URLs preserve interface choice and hash without stale selections", () => {
|
||||
globalThis.window = { location: { pathname: "/lite", search: "?ui=lite&chat=old&search=stale", hash: "#reply" } };
|
||||
try {
|
||||
assert.deepEqual(readSidebarSelectionFromUrl(), { kind: "chat", id: "old" });
|
||||
assert.equal(buildWorkspaceUrl({ kind: "agent", id: "root" }), "/lite?ui=lite&agent=root#reply");
|
||||
assert.equal(buildWorkspaceUrl(null), "/lite?ui=lite#reply");
|
||||
} finally { delete globalThis.window; }
|
||||
});
|
||||
|
||||
test("blocked storage does not prevent reading defaults or using model controls", () => {
|
||||
globalThis.window = { get localStorage() { throw new Error("Storage unavailable"); } };
|
||||
try {
|
||||
assert.equal(loadStoredModelPreferences().openai, null);
|
||||
assert.doesNotThrow(() => saveModelPreferences(normalizeStoredModelPreferences({ openai: "x" })));
|
||||
} finally { delete globalThis.window; }
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
// Keep syntax usable on older module-capable phones and e-readers. Web APIs
|
||||
// still require feature support; this does not add a legacy polyfill bundle.
|
||||
build: { target: ["chrome87", "firefox78", "safari14"] },
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
||||
Reference in New Issue
Block a user