feat(studio): the assistant sits beside the book and draws the page it looks at

A chat column in the Studio with its own privacy notice, a live page snapshot
through modern-screenshot that leaves out photos of other people and foreign
map tiles, and flushes before every turn and apply.
This commit is contained in:
Maurice
2026-10-22 21:25:46 +00:00
parent 8b93898b73
commit 09ffc7d946
47 changed files with 1763 additions and 11 deletions
+14
View File
@@ -0,0 +1,14 @@
import { devices, test } from '@playwright/test';
import { checkPageSnapshot } from './support/pageSnapshotFlow';
// The Studio's page image for the assistant, drawn in WebKit. playwright.config.ts
// has no WebKit project; the engine comes from this file-level device, as in
// e2e/urlbar-scroll-1809.spec.ts. Safari is why the rasteriser is
// modern-screenshot: it decodes images in a foreignObject late, and a page
// drawn too early comes out blank.
test.use({ ...devices['Desktop Safari'] });
test('Studio page snapshot: WebKit draws a seeded spread to a JPEG', async ({ page }) => {
await checkPageSnapshot(page);
});
+10
View File
@@ -0,0 +1,10 @@
import { test } from '@playwright/test';
import { checkPageSnapshot } from './support/pageSnapshotFlow';
// The Studio's page image for the assistant, drawn in Chromium. The same flow
// runs in WebKit from page-snapshot-webkit.spec.ts, the engine with the most
// to get wrong about drawing a page through an SVG foreignObject.
test('Studio page snapshot: Chromium draws a seeded spread to a JPEG', async ({ page }) => {
await checkPageSnapshot(page);
});
+34
View File
@@ -0,0 +1,34 @@
import { expect, type Page } from '@playwright/test';
import type { SeededSnapshot } from './pageSnapshotHarness';
/**
* The page snapshot the Studio sends the assistant, taken in a real browser
* (journey-studio.md 3.1): the harness module is loaded into the running app
* through the dev server, draws a seeded spread with the editor's SpreadView
* and rasterises it with modern-screenshot. The image has to be a non-empty
* JPEG at the size the Studio promises the server, and the map tile from
* another server on the sheet must never have been fetched for it.
*/
export async function checkPageSnapshot(page: Page): Promise<void> {
await page.goto('/dashboard');
const shot = await page.evaluate(async (harness: string): Promise<SeededSnapshot | null> => {
const mod = (await import(/* @vite-ignore */ harness)) as {
snapshotSeededSpread: () => Promise<SeededSnapshot | null>;
};
return mod.snapshotSeededSpread();
}, '/e2e/support/pageSnapshotHarness.ts');
expect(shot, 'the spread was drawn and came out under the size limit').not.toBeNull();
if (!shot) return;
expect(shot.type).toBe('image/jpeg');
expect(shot.head).toEqual([0xff, 0xd8, 0xff]);
expect(shot.bytes).toBeGreaterThan(1_000);
// A double spread at 1280 pixels on its long edge, and the file says so too.
expect(shot.width).toBe(1280);
expect(shot.height).toBe(640);
expect(shot.decodedWidth).toBe(1280);
expect(shot.decodedHeight).toBe(640);
expect(shot.drawnPhotoIds).toEqual([]);
expect(shot.tileRequested, 'the tile from another server is left out before anything is fetched').toBe(false);
}
+91
View File
@@ -0,0 +1,91 @@
import { bookPageSetupForPreset, bookShapeElementSchema, bookTextElementSchema, type BookSpread } from '@trek/shared';
import { createElement } from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { SpreadView } from '../../src/components/Studio/SpreadView';
import { sheetSizePx, snapshotSpread } from '../../src/components/Studio/pageSnapshot';
/**
* Loaded by the page snapshot flows inside the running app, where the dev
* server serves it as a module: draws a seeded spread with the editor's own
* SpreadView and turns it into the JPEG the Studio sends the assistant, so a
* real Chromium and a real WebKit prove the rasteriser works end to end. A map
* tile from another server sits on the sheet and must be left out.
*/
export interface SeededSnapshot {
type: string;
bytes: number;
/** The first bytes of the file: a JPEG starts FF D8 FF. */
head: number[];
width: number;
height: number;
/** The image decoded again, as the browser reads it. */
decodedWidth: number;
decodedHeight: number;
drawnPhotoIds: number[];
/** Whether the snapshot reached the map tile's server at all. */
tileRequested: boolean;
}
const TILE = 'https://tile.openstreetmap.org/5/16/10.png';
function seededSpread(): BookSpread {
return {
id: 'sp-seeded',
role: 'inner',
background: '#f4efe6',
elements: [
bookShapeElementSchema.parse({ id: 'shape', kind: 'shape', frame: { x: 20, y: 20, w: 160, h: 90 } }),
bookTextElementSchema.parse({
id: 'title',
kind: 'text',
frame: { x: 230, y: 30, w: 160, h: 30 },
text: 'Lisbon',
}),
],
parked: [],
entryId: null,
};
}
export async function snapshotSeededSpread(): Promise<SeededSnapshot | null> {
const page = bookPageSetupForPreset('square-210');
const spread = seededSpread();
const size = sheetSizePx(spread, page);
// Off screen as the Studio's stage is; the sheet inside it is what is drawn.
const host = document.createElement('div');
host.style.cssText = 'position:fixed;top:0;inset-inline-start:-20000px';
const sheet = document.createElement('div');
sheet.style.cssText = `position:relative;width:${size.width}px;height:${size.height}px`;
host.appendChild(sheet);
document.body.appendChild(host);
const root = createRoot(sheet);
flushSync(() => root.render(createElement(SpreadView, { spread, page, print: true })));
const tile = document.createElement('img');
tile.src = TILE;
sheet.firstElementChild?.appendChild(tile);
const before = performance.getEntriesByType('resource').length;
try {
const shot = await snapshotSpread(sheet, size, new Set());
if (!shot) return null;
const head = [...new Uint8Array(await shot.image.slice(0, 3).arrayBuffer())];
const bitmap = await createImageBitmap(shot.image);
const fetched = performance.getEntriesByType('resource').slice(before);
return {
type: shot.image.type,
bytes: shot.image.size,
head,
width: shot.width,
height: shot.height,
decodedWidth: bitmap.width,
decodedHeight: bitmap.height,
drawnPhotoIds: shot.drawnPhotoIds,
tileRequested: fetched.some((entry) => entry.name === TILE && entry.initiatorType !== 'img'),
};
} finally {
root.unmount();
host.remove();
}
}
+1
View File
@@ -68,6 +68,7 @@
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"modern-screenshot": "4.7.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
@@ -3,7 +3,7 @@ import { ThreadPrimitive } from '@assistant-ui/react';
import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState';
import AssistantHints, { MAX_VISIBLE_HINTS } from './AssistantHints';
import { useAssistantView } from './assistantViewContext';
import { useAssistantScope, useAssistantView } from './assistantViewContext';
import { useAssistantHints } from './useAssistantHints';
import type { ReadySuggestion } from './useAssistantSuggestions';
@@ -28,12 +28,20 @@ export default function AssistantWelcome({
}) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
// The Studio's chat is about a book, not a plan.
const book = useAssistantScope().kind === 'journey';
const hints = useAssistantHints(tripId, { withStays: true });
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={t('assistant.empty.title')} />
<EmptyState
scene="chat"
size={72}
compact
surface="var(--bg-elevated)"
title={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
/>
{!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted">{t('assistant.empty.readOnly')}</p>
)}
+25 -3
View File
@@ -376,9 +376,18 @@ function IconView({ el }: { el: BookIconElement }) {
}
export function ElementView({
el, big, print = false, dropLabel = '',
}: { el: BookElement; big: boolean; print?: boolean; dropLabel?: string }) {
el, big, print = false, dropLabel = '', hiddenPhotoIds,
}: {
el: BookElement; big: boolean; print?: boolean; dropLabel?: string
/** Photos drawn as empty frames instead of themselves. */
hiddenPhotoIds?: ReadonlySet<number>
}) {
if (el.kind === 'photo') {
// A photo left out is drawn as its empty frame, kept even in print, so the
// page still shows where a picture sits without showing the picture.
if (el.photoId != null && hiddenPhotoIds?.has(el.photoId)) {
return <PhotoView el={{ ...el, photoId: null }} big={big} print={false} dropLabel="" />
}
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
}
@@ -432,6 +441,7 @@ export function SpreadView({
showGuides = false,
print = false,
dropLabel = '',
hiddenPhotoIds,
}: {
spread: BookSpread
page: BookPageSetup
@@ -447,6 +457,11 @@ export function SpreadView({
/** The print renderer passes this: no guides, no placeholders, no chrome. */
print?: boolean
dropLabel?: string
/**
* Photos to leave out, drawn as empty frames: a page drawn for the
* assistant shows only the asking user's own photos (journey-studio.md 3.4).
*/
hiddenPhotoIds?: ReadonlySet<number>
}) {
const isSingle = spread.role !== 'inner'
const w = isSingle ? page.pageWidth : page.pageWidth * 2
@@ -468,7 +483,14 @@ export function SpreadView({
}}
>
{spread.elements.map(el => (
<ElementView key={el.id} el={el} big={big} print={print} dropLabel={dropLabel} />
<ElementView
key={el.id}
el={el}
big={big}
print={print}
dropLabel={dropLabel}
hiddenPhotoIds={hiddenPhotoIds}
/>
))}
<PageNumbers spread={spread} page={page} folios={folios} />
@@ -0,0 +1,108 @@
/**
* FE-STUDIO-ASTPANEL-001 to FE-STUDIO-ASTPANEL-003
*
* The assistant's column in TREK Studio (journey-studio.md 3.4, 4.5): the
* journey's chat with its own question; the notice that images of a page
* leave only once the user agreed, for the connection on screen, and that
* "Not now" keeps it text for the visit; every key inside the column belongs
* to the chat, so Studio neither closes nor edits the page's selection, and
* Escape stops a running answer first.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo';
import { assistantRepo } from '../../repo/assistantRepo';
import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import StudioAssistantPanel from './StudioAssistantPanel';
import type { StudioAssistant } from './useStudioAssistant';
const JOURNEY = 12;
const KEY = assistantChatKey({ kind: 'journey', journeyId: JOURNEY });
const INITIAL = useAssistantStore.getState();
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance',
canConnectOwn: false,
effective: { source: 'instance', kind: 'anthropic', model: 'claude', host: 'api.anthropic.com', paidBy: 'instance' },
own: null,
instance: { kind: 'anthropic', model: 'claude', host: 'api.anthropic.com' },
quota: null,
privacyAcked: true,
studioImagesAcked: false,
retention: { mode: 'days', days: 90 },
openrouterOAuth: false,
};
function assistant(): StudioAssistant {
return { chatKey: KEY, available: true, open: true, toggle: vi.fn(), jobs: [], drawn: vi.fn() };
}
beforeEach(() => {
vi.spyOn(assistantRepo, 'threads').mockResolvedValue([]);
useAssistantStore.setState({ ...INITIAL, status: STATUS, byTrip: {}, studioHosts: {} });
});
afterEach(() => {
vi.restoreAllMocks();
useAssistantStore.setState(INITIAL);
});
describe('StudioAssistantPanel', () => {
it('FE-STUDIO-ASTPANEL-001: shows the journey chat and lets page images leave once the user agreed', async () => {
const ack = vi.spyOn(aiConnectionsRepo, 'ackPrivacy').mockResolvedValueOnce(undefined);
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
expect(screen.getByRole('complementary', { name: 'Book assistant' })).toBeInTheDocument();
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
const notice = screen.getByRole('note');
expect(notice.textContent).toContain('Only that page goes to Anthropic');
fireEvent.click(screen.getByRole('button', { name: 'Show it pages' }));
await waitFor(() => expect(screen.queryByRole('note')).toBeNull());
expect(ack).toHaveBeenCalledWith({ kind: 'anthropic', host: 'api.anthropic.com', scope: 'studio_images' });
expect(useAssistantStore.getState().status?.studioImagesAcked).toBe(true);
});
it('FE-STUDIO-ASTPANEL-002: "Not now" keeps the pages as text, an agreed connection shows no notice', async () => {
const ack = vi.spyOn(aiConnectionsRepo, 'ackPrivacy');
const { unmount } = render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
fireEvent.click(screen.getByRole('button', { name: 'Not now' }));
expect(screen.queryByRole('note')).toBeNull();
expect(ack).not.toHaveBeenCalled();
unmount();
useAssistantStore.setState({ status: { ...STATUS, studioImagesAcked: true } });
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
expect(screen.queryByRole('note')).toBeNull();
});
it('FE-STUDIO-ASTPANEL-003: keys in the column stay there, Escape stops a running answer first', () => {
const cancel = vi.fn();
useAssistantStore.setState({
cancel,
byTrip: { [KEY]: { ...chatOf(useAssistantStore.getState(), KEY), isRunning: true } },
});
const studioKeys = vi.fn();
window.addEventListener('keydown', studioKeys);
const panel = assistant();
render(<StudioAssistantPanel assistant={panel} title="Lisbon" />);
const column = screen.getByRole('complementary', { name: 'Book assistant' });
// The editor deletes the selected elements on Backspace; from the chat it must not hear it.
fireEvent.keyDown(column, { key: 'Backspace' });
expect(cancel).not.toHaveBeenCalled();
fireEvent.keyDown(column, { key: 'Escape' });
expect(studioKeys).not.toHaveBeenCalled();
expect(cancel).toHaveBeenCalledTimes(1);
expect(cancel).toHaveBeenCalledWith(KEY);
expect(panel.toggle).not.toHaveBeenCalled();
window.removeEventListener('keydown', studioKeys);
});
});
@@ -0,0 +1,173 @@
import '../../styles/studio-assistant.css';
import { ImageIcon } from 'lucide-react';
import { type HTMLAttributes, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
import { useAssistantStore } from '../../store/assistantStore';
import AssistantHeader from '../Assistant/AssistantHeader';
import AssistantSurfaceLazy from '../Assistant/AssistantSurfaceLazy';
import type { EntityTarget } from '../Assistant/cards/cardTypes';
import { providerLabel } from '../Assistant/providerCatalog';
import { useAssistantThread } from '../Assistant/useAssistantThread';
import { useToast } from '../shared/Toast';
import { SpreadView } from './SpreadView';
import { sheetSizePx } from './pageSnapshot';
import type { StudioAssistant, StudioRenderJob } from './useStudioAssistant';
/**
* The assistant as a column of TREK Studio (journey-studio.md 4.5): the same
* chat as the planner's window, on the journey's conversations, beside the
* page it talks about. Desktop only, like the Studio itself.
*
* Studio closes on Escape and edits the selection on Delete, copy and paste.
* Inside the column every key belongs to the chat, so it stops here, and
* Escape stops a running answer first.
*/
/** The column is not a window: its head moves nothing. */
const NO_DRAG: HTMLAttributes<HTMLElement> = { style: { cursor: 'default' } };
/**
* Images of a page leave only once the user agreed, per connection. Until
* then the Studio's assistant reads the pages as text. "Not now" hides the
* notice for this visit; the next one asks again.
*/
function StudioImagesNotice() {
const { t } = useTranslation();
const toast = useToast();
const effective = useAssistantStore((s) => s.status?.effective ?? null);
const acked = useAssistantStore((s) => s.status?.studioImagesAcked === true);
const ackStudioImages = useAssistantStore((s) => s.ackStudioImages);
const [later, setLater] = useState(false);
if (!effective || acked || later) return null;
const provider = providerLabel(effective.kind, effective.host);
const allow = async () => {
try {
await ackStudioImages();
} catch {
toast.error(t('assistant.error.generic'));
}
};
return (
<div
role="note"
className="st-assistant-notice rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 text-caption text-content-secondary"
>
<div className="flex items-start gap-2.5">
<ImageIcon size={14} aria-hidden="true" className="mt-0.5 flex-shrink-0 text-content-muted" />
<p className="m-0 min-w-0 flex-1">{t('journey.studio.assistant.notice', { provider })}</p>
</div>
<div className="mt-2 flex items-center justify-end gap-1">
<button
type="button"
onClick={() => setLater(true)}
className="rounded-md px-2 py-1 text-caption text-content-muted hover:bg-surface-hover hover:text-content"
>
{t('journey.studio.assistant.noticeLater')}
</button>
<button
type="button"
onClick={() => void allow()}
className="rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
>
{t('journey.studio.assistant.noticeAllow')}
</button>
</div>
</div>
);
}
/** One page a turn asked for, drawn as the editor draws it and handed on once it is in the DOM. */
function StageSheet({
job,
onDrawn,
}: {
job: StudioRenderJob;
onDrawn: (job: StudioRenderJob, sheet: HTMLElement) => Promise<void>;
}) {
const sheet = useRef<HTMLDivElement>(null);
// A job goes up once, even when the effect runs again (development's double run, a new callback).
const sent = useRef<StudioRenderJob | null>(null);
const size = sheetSizePx(job.spread, job.page);
useEffect(() => {
if (!sheet.current || sent.current === job) return;
sent.current = job;
void onDrawn(job, sheet.current);
}, [job, onDrawn]);
return (
// A page is laid out in millimetres from its top left whatever the reading direction.
<div ref={sheet} dir="ltr" style={{ position: 'relative', width: size.width, height: size.height }}>
<SpreadView spread={job.spread} page={job.page} folios={job.folios} print hiddenPhotoIds={job.hidden} />
</div>
);
}
/**
* The pages being drawn for a turn, where nobody sees them. Off screen rather
* than hidden, as StudioExport does it: a hidden subtree lays nothing out,
* and the page is measured in millimetres by the CSS that draws it.
*/
export function StudioPageStage({ assistant }: { assistant: StudioAssistant }) {
if (assistant.jobs.length === 0) return null;
return (
<div
aria-hidden="true"
className="st-assistant-stage"
style={{ position: 'fixed', insetInlineStart: '-20000mm', top: 0, pointerEvents: 'none' }}
>
{assistant.jobs.map((job) => (
<StageSheet key={job.request.requestId} job={job} onDrawn={assistant.drawn} />
))}
</div>
);
}
export default function StudioAssistantPanel({ assistant, title }: { assistant: StudioAssistant; title: string }) {
const { t } = useTranslation();
const navigate = useNavigate();
const thread = useAssistantThread(assistant.chatKey);
const { isRunning, stop } = thread;
// A card points at a page of this book (`?page=`) or somewhere else in the app.
const onOpen = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') void navigate(target.path);
},
[navigate]
);
// Every key pressed in the column is the chat's: the editor's own keys
// (Delete, Backspace, copy, paste, undo, Escape) listen on the window and
// would otherwise act on the page's selection from a focused chat button.
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
e.stopPropagation();
if (e.key === 'Escape' && isRunning && !e.defaultPrevented) stop();
};
return (
<aside className="st-panel st-assistant" aria-label={t('journey.studio.assistant.panel')} onKeyDown={onKeyDown}>
<AssistantHeader
tripId={assistant.chatKey}
thread={thread}
tripTitle={title}
dragProps={NO_DRAG}
onClose={assistant.toggle}
/>
<StudioImagesNotice />
<AssistantSurfaceLazy
tripId={assistant.chatKey}
tripTitle={title}
variant="window"
density="compact"
onOpen={onOpen}
/>
</aside>
);
}
+37 -5
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router'
import {
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, MessageSquare, Minus, Plus,
Redo2, Sparkles, Undo2,
} from 'lucide-react'
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
@@ -21,6 +21,8 @@ import { PeerBadges } from './PeerBadges'
import { TrimField } from './TrimField'
import { Tooltip } from '../shared/Tooltip'
import { useStudioStore } from '../../store/studioStore'
import StudioAssistantPanel, { StudioPageStage } from './StudioAssistantPanel'
import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
import './bookFontFaces'
@@ -42,6 +44,7 @@ export default function StudioShell() {
const navigate = useNavigate()
const [bookView, setBookView] = useState(true)
const [exporting, setExporting] = useState(false)
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
if (s.isMobile) {
return createPortal(
@@ -83,7 +86,13 @@ export default function StudioShell() {
aria-modal="true"
aria-label={s.t('journey.studio.title')}
>
<StudioBar s={s} bookView={bookView} setBookView={setBookView} onExport={() => setExporting(true)} />
<StudioBar
s={s}
bookView={bookView}
setBookView={setBookView}
onExport={() => setExporting(true)}
assistant={assistant}
/>
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
{s.providerPick && s.journey && (
<ProviderPicker
@@ -98,7 +107,7 @@ export default function StudioShell() {
/>
)}
<div className="st-body">
<div className={`st-body ${assistant.open ? 'has-assistant' : ''}`}>
<StudioSidebar
page={s.page}
pxPerMm={s.pxPerMm}
@@ -124,7 +133,9 @@ export default function StudioShell() {
t={s.t}
locale={s.locale}
/>
<AssistantColumn s={s} assistant={assistant} />
</div>
<StudioPageStage assistant={assistant} />
{exporting && s.doc && (
<StudioExport
@@ -142,9 +153,18 @@ export default function StudioShell() {
type Studio = ReturnType<typeof useJourneyStudio>
/** The chat column at the end of the body, while it is open. */
function AssistantColumn({ s, assistant }: { s: Studio; assistant: StudioAssistant }) {
if (!assistant.open) return null
return <StudioAssistantPanel assistant={assistant} title={s.journey?.title || s.t('journey.title')} />
}
function StudioBar({
s, bookView, setBookView, onExport,
}: { s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void }) {
s, bookView, setBookView, onExport, assistant,
}: {
s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void
assistant: StudioAssistant
}) {
return (
<div className="st-bar">
<button type="button" onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
@@ -217,6 +237,18 @@ function StudioBar({
<div className="st-sep" />
<AutoLayoutButton s={s} />
{/* The book's assistant, as a column beside the page; only where it can run for this user. */}
{assistant.available && (
<button type="button"
className={`st-tool ${assistant.open ? 'is-on' : ''}`}
onClick={assistant.toggle}
aria-pressed={assistant.open}
title={s.t('journey.studio.assistant.toggleHint')}
>
<MessageSquare size={14} />
<span className="st-tool-label">{s.t('journey.studio.assistant.toggle')}</span>
</button>
)}
<button type="button"
className="st-tool is-primary"
onClick={onExport}
@@ -0,0 +1,114 @@
/**
* FE-STUDIO-SNAP-001 to FE-STUDIO-SNAP-005
*
* The page image the Studio sends the assistant (journey-studio.md 3.1, 3.4):
* a sheet's size in CSS pixels, the filter that leaves out pictures from
* other servers and hidden photos before anything is rasterised, the photos
* the upload declares read off what is kept, and the JPEG at most 1280 pixels
* on its long edge. modern-screenshot is faked; a real browser draws the page
* in the Playwright flows.
*/
import { ASSISTANT_PAGE_IMAGE, blankBook, bookPageSetupForPreset } from '@trek/shared';
import { domToBlob, type Options } from 'modern-screenshot';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { drawnPhotoIdsOf, keepNode, sheetSizePx, snapshotSpread } from './pageSnapshot';
vi.mock('modern-screenshot', () => ({ domToBlob: vi.fn() }));
const SVG = 'http://www.w3.org/2000/svg';
function picture(src: string): HTMLImageElement {
const img = document.createElement('img');
img.setAttribute('src', src);
return img;
}
function svgImage(href: string): Element {
const image = document.createElementNS(SVG, 'image');
image.setAttribute('href', href);
return image;
}
/** A sheet holding an own photo twice, a hidden photo, a map tile and a stop's own photo. */
function sheet(): HTMLDivElement {
const root = document.createElement('div');
root.append(
picture('/api/photos/7/thumbnail'),
picture('/api/photos/7/thumbnail'),
picture('/api/photos/31/thumbnail'),
picture('https://tile.openstreetmap.org/5/16/10.png')
);
const svg = document.createElementNS(SVG, 'svg');
svg.append(svgImage('/api/photos/8/thumbnail'), svgImage('/api/photos/32/thumbnail'));
root.append(svg);
return root;
}
const HIDDEN: ReadonlySet<number> = new Set([31, 32]);
/** The calls made to the fake, as the node and options form of domToBlob takes them. */
const blobCalls = () => vi.mocked(domToBlob).mock.calls as unknown as Array<[Node, Options | undefined]>;
afterEach(() => vi.clearAllMocks());
describe('pageSnapshot', () => {
it('FE-STUDIO-SNAP-001: a cover is one page wide and an inner spread two, in CSS pixels', () => {
const page = bookPageSetupForPreset('square-210');
const [cover, , inner] = blankBook({ title: 'Lisbon', page }).spreads;
if (!cover || !inner) throw new Error('blank book without a cover or an inner spread');
const px = 210 * (96 / 25.4);
expect(sheetSizePx(cover, page)).toEqual({ width: px, height: px });
expect(sheetSizePx(inner, page)).toEqual({ width: px * 2, height: px });
});
it('FE-STUDIO-SNAP-002: leaves out pictures from other servers and hidden photos, keeps the rest', () => {
expect(keepNode(picture('/api/photos/7/thumbnail'), HIDDEN)).toBe(true);
expect(keepNode(picture('data:image/png;base64,AAAA'), HIDDEN)).toBe(true);
expect(keepNode(picture('/api/photos/31/original'), HIDDEN)).toBe(false);
expect(keepNode(picture('https://tile.openstreetmap.org/5/16/10.png'), HIDDEN)).toBe(false);
expect(keepNode(svgImage('/api/photos/32/thumbnail'), HIDDEN)).toBe(false);
expect(keepNode(svgImage('https://api.mapbox.com/styles/v1/x/static/0,0,1/400x300'), HIDDEN)).toBe(false);
// Everything that is not a picture is drawn as it is.
expect(keepNode(document.createElement('div'), HIDDEN)).toBe(true);
expect(keepNode(document.createTextNode('Lisbon'), HIDDEN)).toBe(true);
});
it('FE-STUDIO-SNAP-003: declares each kept photo once, and none it left out', () => {
expect(drawnPhotoIdsOf(sheet(), HIDDEN).sort((a, b) => a - b)).toEqual([7, 8]);
expect(drawnPhotoIdsOf(sheet(), new Set()).sort((a, b) => a - b)).toEqual([7, 8, 31, 32]);
});
it('FE-STUDIO-SNAP-004: draws a JPEG with its long edge at 1280 pixels through the same filter', async () => {
const image = new Blob(['jpeg'], { type: 'image/jpeg' });
vi.mocked(domToBlob).mockResolvedValueOnce(image);
const root = sheet();
const shot = await snapshotSpread(root, { width: 1600, height: 800 }, HIDDEN);
expect(shot).toEqual({ image, width: 1280, height: 640, drawnPhotoIds: [7, 8] });
const [node, options] = blobCalls()[0] ?? [];
expect(node).toBe(root);
expect(options).toMatchObject({
width: 1600,
height: 800,
scale: 0.8,
type: 'image/jpeg',
quality: ASSISTANT_PAGE_IMAGE.jpegQuality,
});
const filter = options?.filter;
expect(filter?.(picture('/api/photos/31/thumbnail'))).toBe(false);
expect(filter?.(picture('/api/photos/7/thumbnail'))).toBe(true);
});
it('FE-STUDIO-SNAP-005: an image too large for the server is not sent, a small sheet is not enlarged', async () => {
vi.mocked(domToBlob).mockResolvedValueOnce(new Blob([new Uint8Array(ASSISTANT_PAGE_IMAGE.maxBytes + 1)]));
expect(await snapshotSpread(sheet(), { width: 1600, height: 800 }, HIDDEN)).toBeNull();
vi.mocked(domToBlob).mockResolvedValueOnce(new Blob(['jpeg'], { type: 'image/jpeg' }));
const small = await snapshotSpread(sheet(), { width: 400, height: 300 }, HIDDEN);
expect(small).toMatchObject({ width: 400, height: 300 });
expect(blobCalls()[1]?.[1]).toMatchObject({ scale: 1 });
});
});
@@ -0,0 +1,146 @@
import { ASSISTANT_PAGE_IMAGE, type BookPageSetup, type BookSpread } from '@trek/shared';
/**
* One Studio page as a JPEG, for the assistant to look at (journey-studio.md
* 3.1). The page is the editor's own SpreadView, drawn off screen, and this
* turns that DOM into an image through modern-screenshot, which is loaded
* only when a page is first drawn, so nobody downloads it before the Studio
* chat asks for one.
*
* What is drawn is what is sent, so the filter runs here, on the DOM, before
* anything is rasterised: a picture from another server (map tiles, a static
* map) is left out, because it would taint the canvas and say where the user
* is, and so is any photo the turn named as hidden. The photos left in are
* read off the DOM itself, so the upload declares exactly what the image shows.
*/
/** CSS pixels in a millimetre, the size SpreadView's millimetre boxes are laid out at. */
const PX_PER_MM = 96 / 25.4;
/** How long the pictures of one page may take to load before it is drawn without them. */
const LOAD_TIMEOUT_MS = 8_000;
/** The path of a TREK photo, with its id. */
const PHOTO_PATH = /^\/api\/photos\/(\d+)\//;
interface PageSnapshot {
image: Blob;
width: number;
height: number;
/** The photos the image shows, as the server checks them. */
drawnPhotoIds: number[];
}
/** A sheet's size in CSS pixels: one page for the covers and the first and last pages, two for an inner spread. */
export function sheetSizePx(spread: BookSpread, page: BookPageSetup): { width: number; height: number } {
const pages = spread.role === 'inner' ? 2 : 1;
return { width: page.pageWidth * pages * PX_PER_MM, height: page.pageHeight * PX_PER_MM };
}
/** Where a picture element takes its image from: an img's src, an SVG image's href. */
function sourceOf(node: Element): string | null {
const tag = node.tagName.toLowerCase();
if (tag === 'img') return node.getAttribute('src');
if (tag === 'image') return node.getAttribute('href') ?? node.getAttribute('xlink:href');
return null;
}
/** A picture's URL read against this page, or null when it is not one. */
function urlOf(src: string): URL | null {
try {
return new URL(src, window.location.href);
} catch {
return null;
}
}
/** The TREK photo a picture shows, or null for any other picture. */
function photoIdOf(url: URL): number | null {
const match = PHOTO_PATH.exec(url.pathname);
return match ? Number(match[1]) : null;
}
/** Whether a picture may be drawn: an inline one, or one from this server that is not a hidden photo. */
function drawable(src: string, hidden: ReadonlySet<number>): boolean {
if (src.startsWith('data:')) return true;
const url = urlOf(src);
if (!url || url.origin !== window.location.origin) return false;
const photoId = photoIdOf(url);
return photoId === null || !hidden.has(photoId);
}
/** Whether a node of the page goes into the image. */
export function keepNode(node: Node, hidden: ReadonlySet<number>): boolean {
if (!(node instanceof Element)) return true;
const src = sourceOf(node);
return src === null || drawable(src, hidden);
}
/** The photos the kept pictures of a page show, each once. */
export function drawnPhotoIdsOf(root: Element, hidden: ReadonlySet<number>): number[] {
const ids = new Set<number>();
root.querySelectorAll('img, image').forEach((node) => {
const src = sourceOf(node);
const url = src !== null && drawable(src, hidden) ? urlOf(src) : null;
const photoId = url ? photoIdOf(url) : null;
if (photoId !== null) ids.add(photoId);
});
return [...ids];
}
/** Waits for one picture, loading it now if it was lazy. */
async function decoded(img: HTMLImageElement): Promise<void> {
img.loading = 'eager';
try {
await img.decode();
} catch {
// Drawn as the browser has it: a picture that cannot be decoded leaves its frame, and the page still goes.
}
}
/** Waits for the page's own pictures, at most `LOAD_TIMEOUT_MS`. */
async function picturesLoaded(root: Element, hidden: ReadonlySet<number>): Promise<void> {
const loads = [...root.querySelectorAll('img')].flatMap((img) => {
const src = img.getAttribute('src');
return src !== null && drawable(src, hidden) ? [decoded(img)] : [];
});
let timer: ReturnType<typeof setTimeout> | undefined;
const late = new Promise<void>((resolve) => {
timer = setTimeout(resolve, LOAD_TIMEOUT_MS);
});
await Promise.race([Promise.all(loads), late]);
clearTimeout(timer);
}
/**
* The sheet under `root` as a JPEG, its long edge at most
* `ASSISTANT_PAGE_IMAGE.longEdge` pixels. `size` is the sheet's own size in
* CSS pixels (`sheetSizePx`). Null when the image comes out larger than the
* server takes, so the turn goes on with the page's description.
*/
export async function snapshotSpread(
root: HTMLElement,
size: { width: number; height: number },
hiddenPhotoIds: ReadonlySet<number>
): Promise<PageSnapshot | null> {
await picturesLoaded(root, hiddenPhotoIds);
const scale = Math.min(1, ASSISTANT_PAGE_IMAGE.longEdge / Math.max(size.width, size.height));
const { domToBlob } = await import('modern-screenshot');
const image = await domToBlob(root, {
width: size.width,
height: size.height,
scale,
type: 'image/jpeg',
quality: ASSISTANT_PAGE_IMAGE.jpegQuality,
filter: (node) => keepNode(node, hiddenPhotoIds),
timeout: LOAD_TIMEOUT_MS,
fetch: { requestInit: { credentials: 'same-origin', cache: 'force-cache' } },
});
if (image.size > ASSISTANT_PAGE_IMAGE.maxBytes) return null;
return {
image,
width: Math.round(size.width * scale),
height: Math.round(size.height * scale),
drawnPhotoIds: drawnPhotoIdsOf(root, hiddenPhotoIds),
};
}
@@ -0,0 +1,238 @@
/**
* FE-STUDIO-AST-001 to FE-STUDIO-AST-007
*
* What TREK Studio does for its assistant (journey-studio.md 3.1, 4.5): it
* hosts the journey's chat while the column shows; it draws the page a turn
* asks for off screen, with other members' photos as empty frames, and sends
* it at the version the turn named; a page of a design draft comes from the
* draft; a preview runs on an unsaved copy; each question carries the page on
* screen; `?page=` shows a page. The snapshot
* and the repo are faked: a real browser draws the page in the Playwright flows.
*/
import {
type AiStatusResponse,
type AssistantPageRender,
blankBook,
type BookDocument,
bookPhotoElementSchema,
} from '@trek/shared';
import { useLocation } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { assistantRepo } from '../../repo/assistantRepo';
import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useStudioStore } from '../../store/studioStore';
import { StudioPageStage } from './StudioAssistantPanel';
import { snapshotSpread } from './pageSnapshot';
import { renderJobOf, useStudioAssistant } from './useStudioAssistant';
vi.mock('./pageSnapshot', async (importOriginal) => ({
...(await importOriginal<typeof import('./pageSnapshot')>()),
snapshotSpread: vi.fn(),
}));
const JOURNEY = 12;
const KEY = assistantChatKey({ kind: 'journey', journeyId: JOURNEY });
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance',
canConnectOwn: false,
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
own: null,
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
quota: null,
privacyAcked: true,
studioImagesAcked: true,
retention: { mode: 'days', days: 90 },
openrouterOAuth: false,
};
const REQUEST: AssistantPageRender = {
requestId: '5f0c3a52-8d1e-4b7a-9c2f-0a1b2c3d4e5f',
threadId: 9,
spreadId: 'sp-3',
bookVersion: 4,
hiddenPhotoIds: [31],
};
/** A blank book (sp-1 to sp-5) with an own photo and another member's on the inner spread sp-3. */
function book(): BookDocument {
let seq = 0;
const doc = blankBook({ title: 'Lisbon', newId: (prefix) => `${prefix}-${++seq}` });
const own = bookPhotoElementSchema.parse({
id: 'p-own',
kind: 'photo',
frame: { x: 10, y: 10, w: 80, h: 60 },
photoId: 7,
});
const theirs = bookPhotoElementSchema.parse({
id: 'p-theirs',
kind: 'photo',
frame: { x: 230, y: 10, w: 80, h: 60 },
photoId: 31,
});
return { ...doc, spreads: doc.spreads.map((s) => (s.id === 'sp-3' ? { ...s, elements: [own, theirs] } : s)) };
}
function record(version: number) {
return { id: 1, journeyId: JOURNEY, title: 'Lisbon', version, updatedAt: null, updatedBy: null, document: book() };
}
function Location() {
return <output data-testid="search">{useLocation().search}</output>;
}
function Harness({ flush }: { flush: () => Promise<boolean> }) {
const assistant = useStudioAssistant({ journeyId: JOURNEY, flush });
return (
<>
<button type="button" onClick={assistant.toggle}>
{assistant.open ? 'close' : 'open'}
</button>
<StudioPageStage assistant={assistant} />
<Location />
</>
);
}
const flush = vi.fn(async () => true);
const host = () => useAssistantStore.getState().studioHosts[KEY];
beforeEach(() => {
window.localStorage.setItem('trek.studioAssistant', '1');
useAssistantStore.setState({ status: STATUS, byTrip: {}, studioHosts: {} });
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0 });
});
afterEach(() => {
vi.restoreAllMocks();
vi.mocked(snapshotSpread).mockReset();
window.localStorage.clear();
});
describe('useStudioAssistant', () => {
it('FE-STUDIO-AST-001: hosts the chat while the column shows, remembered, never without an assistant', async () => {
render(<Harness flush={flush} />);
expect(host()).toBeDefined();
await act(async () => {
await host()?.flush();
});
expect(flush).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'close' }));
expect(host()).toBeUndefined();
expect(window.localStorage.getItem('trek.studioAssistant')).toBe('0');
fireEvent.click(screen.getByRole('button', { name: 'open' }));
expect(host()).toBeDefined();
act(() => useAssistantStore.setState({ status: { ...STATUS, available: false } }));
expect(host()).toBeUndefined();
});
it('FE-STUDIO-AST-002: draws the page asked for with other members photos left empty, and sends it', async () => {
vi.spyOn(assistantRepo, 'pageBook').mockResolvedValueOnce(record(4));
const upload = vi.spyOn(assistantRepo, 'uploadRender').mockResolvedValueOnce(undefined);
const image = new Blob(['jpeg'], { type: 'image/jpeg' });
let drawnSheet: HTMLElement | null = null;
vi.mocked(snapshotSpread).mockImplementationOnce(async (sheet) => {
drawnSheet = sheet;
return { image, width: 1280, height: 640, drawnPhotoIds: [7] };
});
const { container } = render(<Harness flush={flush} />);
act(() => host()?.render(REQUEST, new AbortController().signal));
await waitFor(() => expect(upload).toHaveBeenCalledTimes(1));
expect(upload.mock.calls[0]?.slice(0, 4)).toEqual([
JOURNEY,
9,
REQUEST.requestId,
{ image, bookVersion: 4, spreadId: 'sp-3', drawnPhotoIds: [7] },
]);
const sheet = drawnSheet as HTMLElement | null;
expect(sheet?.querySelector('img[src="/api/photos/7/thumbnail"]')).not.toBeNull();
expect(sheet?.querySelector('img[src*="/api/photos/31/"]')).toBeNull();
// The stage empties once the page is sent.
await waitFor(() => expect(container.querySelector('.st-assistant-stage')).toBeNull());
});
it('FE-STUDIO-AST-003: a book that moved on past the version asked for is not drawn or sent', async () => {
vi.spyOn(assistantRepo, 'pageBook').mockResolvedValueOnce(record(5));
const upload = vi.spyOn(assistantRepo, 'uploadRender');
render(<Harness flush={flush} />);
await act(async () => {
host()?.render(REQUEST, new AbortController().signal);
await Promise.resolve();
});
expect(assistantRepo.pageBook).toHaveBeenCalledWith(JOURNEY);
expect(snapshotSpread).not.toHaveBeenCalled();
expect(upload).not.toHaveBeenCalled();
});
it('FE-STUDIO-AST-007: a page of a design draft is drawn from the draft, never from the book', async () => {
const draft = book();
const designed = {
...draft,
spreads: draft.spreads.map((s) => (s.id === 'sp-3' ? { ...s, background: '#abcdef' } : s)),
};
const fromDraft = vi.spyOn(assistantRepo, 'pageDraft').mockResolvedValueOnce({ version: 4, document: designed });
const fromBook = vi.spyOn(assistantRepo, 'pageBook');
const upload = vi.spyOn(assistantRepo, 'uploadRender').mockResolvedValueOnce(undefined);
let background = '';
vi.mocked(snapshotSpread).mockImplementationOnce(async (sheet) => {
background = (sheet.firstElementChild as HTMLElement | null)?.style.background ?? '';
return { image: new Blob(['jpeg']), width: 1280, height: 640, drawnPhotoIds: [7] };
});
render(<Harness flush={flush} />);
act(() => host()?.render({ ...REQUEST, designId: 'd-1' }, new AbortController().signal));
await waitFor(() => expect(upload).toHaveBeenCalledTimes(1));
expect(fromDraft).toHaveBeenCalledWith(JOURNEY, 'd-1');
expect(fromBook).not.toHaveBeenCalled();
// The draft's background, however the DOM spells the colour back.
expect(background).toMatch(/#abcdef|171, 205, 239/i);
});
it('FE-STUDIO-AST-004:a preview runs its ops on an unsaved copy, nothing is drawn when they fail', () => {
const doc = book();
const deadline = { signal: new AbortController().signal, release: vi.fn() };
const job = renderJobOf(doc, { ...REQUEST, ops: [{ op: 'background', color: '#123456' }] }, deadline);
expect(job?.spread.background).toBe('#123456');
expect(job?.folios.length).toBeGreaterThan(0);
expect([...(job?.hidden ?? [])]).toEqual([31]);
expect(doc.spreads[2]?.background).toBeNull();
expect(renderJobOf(doc, { ...REQUEST, spreadId: 'sp-99' }, deadline)).toBeNull();
// An element the page does not hold refuses the whole edit.
const missing: AssistantPageRender = { ...REQUEST, ops: [{ op: 'remove', elementIds: ['nope'], park: true }] };
expect(renderJobOf(doc, missing, deadline)).toBeNull();
});
it('FE-STUDIO-AST-005: each question carries the page on screen and the elements selected on it', () => {
act(() => {
useStudioStore.getState().load(book());
useStudioStore.getState().setActiveSpread(2);
useStudioStore.getState().select(['p-own', 'not an id!']);
});
render(<Harness flush={flush} />);
expect(chatOf(useAssistantStore.getState(), KEY).focus).toEqual({ spreadId: 'sp-3', elementIds: ['p-own'] });
act(() => useStudioStore.getState().select([]));
expect(chatOf(useAssistantStore.getState(), KEY).focus).toEqual({ spreadId: 'sp-3' });
});
it('FE-STUDIO-AST-006: ?page= shows that page and leaves the address', async () => {
act(() => useStudioStore.getState().load(book()));
render(<Harness flush={flush} />, { initialEntries: ['/journey/12/studio?page=sp-4'] });
await waitFor(() => expect(useStudioStore.getState().activeSpread).toBe(3));
await waitFor(() => expect(screen.getByTestId('search').textContent).toBe(''));
});
});
@@ -0,0 +1,269 @@
import {
ASSISTANT_PAGE_IMAGE,
applyBookOps,
bookElementIdSchema,
bookSpreadIdSchema,
type AssistantPageRender,
type BookDocument,
type BookPageSetup,
type BookSpread,
} from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { assistantRepo } from '../../repo/assistantRepo';
import { assistantChatKey } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore';
import { useStudioStore } from '../../store/studioStore';
import { foliosOf } from './bookSheets';
import { sheetSizePx, snapshotSpread } from './pageSnapshot';
/**
* The assistant inside TREK Studio (journey-studio.md 4.5): whether its column
* is open, the journey's chat it hosts, and what the Studio does for that chat.
*
* - Before a turn or an apply goes out, the book's local work is written
* (`flush`), so the assistant edits the book the user sees.
* - Each question carries the page on screen and the elements selected on it.
* - A page a turn asks to look at is drawn here, by the editor's own
* SpreadView off screen, and uploaded as an image (3.1): a page of the
* book, or of a design draft the assistant has not applied yet.
* - `?page=<spread id>`, which a page card opens, shows that page.
*/
/** Where the column's open state is kept between visits, like the planner's assistant window. */
const OPEN_STORAGE = 'trek.studioAssistant';
/** Element ids a question's focus carries at most, as the contract takes them. */
const MAX_FOCUS_ELEMENTS = 30;
/** One page a turn asked for, ready for the stage to draw. */
export interface StudioRenderJob {
request: AssistantPageRender;
spread: BookSpread;
page: BookPageSetup;
folios: number[];
hidden: ReadonlySet<number>;
/** Ends with the turn, or once the turn has stopped waiting for the page. */
signal: AbortSignal;
/** Lets go of the turn's signal once the page is sent or given up. */
release: () => void;
}
function readOpen(): boolean {
try {
return window.localStorage.getItem(OPEN_STORAGE) === '1';
} catch {
return false;
}
}
function writeOpen(open: boolean): void {
try {
window.localStorage.setItem(OPEN_STORAGE, open ? '1' : '0');
} catch {
// Storage refused (a private window): the column stays as it is for this visit only.
}
}
/** The turn's signal, also ended once the turn has stopped waiting for the page. */
function withDeadline(signal: AbortSignal): { signal: AbortSignal; release: () => void } {
const controller = new AbortController();
const stop = () => controller.abort();
const timer = setTimeout(stop, ASSISTANT_PAGE_IMAGE.timeoutMs);
signal.addEventListener('abort', stop, { once: true });
if (signal.aborted) stop();
return {
signal: controller.signal,
release: () => {
clearTimeout(timer);
signal.removeEventListener('abort', stop);
},
};
}
/**
* The page a request asks for, from the book at the version it names: with
* the request's ops applied to an unsaved copy (a preview). Null when the
* page is gone or the ops do not apply, so nothing is drawn.
*/
export function renderJobOf(
doc: BookDocument,
request: AssistantPageRender,
deadline: { signal: AbortSignal; release: () => void }
): StudioRenderJob | null {
let next = doc;
if (request.ops) {
const made = applyBookOps(doc, { spreadId: request.spreadId, ops: request.ops });
if (made.ok === false) return null;
next = made.doc;
}
const index = next.spreads.findIndex((s) => s.id === request.spreadId);
const spread = next.spreads[index];
if (!spread) return null;
return {
request,
spread,
page: next.page,
folios: foliosOf(next.spreads, index, next.page.pageNumbers.startAt),
hidden: new Set(request.hiddenPhotoIds),
...deadline,
};
}
/**
* The job for a request, from the book as the server holds it, or from the
* design draft the request names (`designId`, a page of a whole-book design
* not applied yet); null when the page cannot be drawn.
*/
async function jobFor(
journeyId: number,
request: AssistantPageRender,
deadline: { signal: AbortSignal; release: () => void }
): Promise<StudioRenderJob | null> {
try {
const book = request.designId
? await assistantRepo.pageDraft(journeyId, request.designId)
: await assistantRepo.pageBook(journeyId);
// Moved on since the turn asked: an image of another version would be refused, so it goes on as text.
if (!book || book.version !== request.bookVersion || deadline.signal.aborted) return null;
return renderJobOf(book.document, request, deadline);
} catch {
// Offline or refused: the turn waits out its time and goes on with the page's description.
return null;
}
}
/** A drawn sheet as an image, sent to the turn that asked for it. */
async function sendPage(journeyId: number, job: StudioRenderJob, sheet: HTMLElement): Promise<void> {
if (job.signal.aborted) return;
const shot = await snapshotSpread(sheet, sheetSizePx(job.spread, job.page), job.hidden);
if (!shot || job.signal.aborted) return;
const { bookVersion, spreadId, threadId, requestId } = job.request;
const page = { image: shot.image, bookVersion, spreadId, drawnPhotoIds: shot.drawnPhotoIds };
await assistantRepo.uploadRender(journeyId, threadId, requestId, page, job.signal);
}
/** The pages turns ask for: read at their version, queued for the stage, drawn and uploaded. */
function usePageRenders(journeyId: number) {
const [jobs, setJobs] = useState<StudioRenderJob[]>([]);
const prepare = useCallback(
async (request: AssistantPageRender, signal: AbortSignal) => {
const deadline = withDeadline(signal);
const job = await jobFor(journeyId, request, deadline);
if (job) setJobs((current) => [...current, job]);
else deadline.release();
},
[journeyId]
);
const render = useCallback(
(request: AssistantPageRender, signal: AbortSignal) => {
void prepare(request, signal);
},
[prepare]
);
/** The stage drew a job's page: turn it into an image and send it, then take it off the stage. */
const drawn = useCallback(
async (job: StudioRenderJob, sheet: HTMLElement) => {
try {
await sendPage(journeyId, job, sheet);
} catch {
// A page that cannot be drawn or sent is never the user's error: the turn goes on with the description.
} finally {
job.release();
setJobs((current) => current.filter((j) => j !== job));
}
},
[journeyId]
);
return { jobs, render, drawn };
}
/** Each question carries the page on screen and the elements selected on it, as far as the contract takes them. */
function useStudioFocus(key: number, open: boolean) {
const spreadId = useStudioStore((st) => st.doc?.spreads[st.activeSpread]?.id ?? null);
const selection = useStudioStore((st) => st.selection);
const setFocus = useAssistantStore((s) => s.setFocus);
useEffect(() => {
if (!open) return;
if (spreadId === null || !bookSpreadIdSchema.safeParse(spreadId).success) {
setFocus(key, null);
return;
}
const elementIds = selection.filter((id) => bookElementIdSchema.safeParse(id).success).slice(0, MAX_FOCUS_ELEMENTS);
setFocus(key, elementIds.length > 0 ? { spreadId, elementIds } : { spreadId });
}, [open, key, spreadId, selection, setFocus]);
}
/** `?page=<spread id>`, which a page card opens: shows that page once the book is there, then leaves the address. */
function usePageParam() {
const [params, setParams] = useSearchParams();
const wanted = params.get('page');
const loaded = useStudioStore((st) => st.doc !== null);
const setActiveSpread = useStudioStore((st) => st.setActiveSpread);
useEffect(() => {
if (wanted === null || !loaded) return;
const index = useStudioStore.getState().doc?.spreads.findIndex((s) => s.id === wanted) ?? -1;
if (index >= 0) setActiveSpread(index);
setParams(
(current) => {
const next = new URLSearchParams(current);
next.delete('page');
return next;
},
{ replace: true }
);
}, [wanted, loaded, setActiveSpread, setParams]);
}
export function useStudioAssistant({ journeyId, flush }: { journeyId: number; flush: () => Promise<boolean> }) {
const key = assistantChatKey({ kind: 'journey', journeyId });
const available = useAssistantStore((s) => s.status?.available === true);
const hasStatus = useAssistantStore((s) => s.status !== null);
const loadStatus = useAssistantStore((s) => s.loadStatus);
const hostStudio = useAssistantStore((s) => s.hostStudio);
const [open, setOpen] = useState(readOpen);
const renders = usePageRenders(journeyId);
const { render } = renders;
const showing = open && available;
// The planner's window loads it too; the Studio may be the first to ask.
useEffect(() => {
if (!hasStatus) void loadStatus();
}, [hasStatus, loadStatus]);
// The chat is hosted while its column shows: closing it ends the chat's turn anyway.
useEffect(() => {
if (!showing) return undefined;
return hostStudio(key, { render, flush });
}, [showing, key, render, flush, hostStudio]);
useStudioFocus(key, showing);
usePageParam();
const toggle = useCallback(() => {
writeOpen(!open);
setOpen(!open);
}, [open]);
return {
/** The chat's key in the assistant store. */
chatKey: key,
/** Whether the assistant can run for this user at all; the toggle shows only then. */
available,
/** Whether the chat column shows. */
open: showing,
toggle,
/** The pages being drawn for a turn, for the stage. */
jobs: renders.jobs,
drawn: renders.drawn,
};
}
export type StudioAssistant = ReturnType<typeof useStudioAssistant>;
+23 -1
View File
@@ -1,5 +1,5 @@
/**
* FE-AI-MSG-001 to FE-AI-MSG-009
* FE-AI-MSG-001 to FE-AI-MSG-010
*
* The stream is folded into messages of the shape the server stores, so a
* chat that was streamed and one that was reloaded render the same. The
@@ -196,4 +196,26 @@ describe('markDecisions and closeUndo', () => {
data: { ...applied, undoUntil: null },
});
});
it('FE-AI-MSG-010: a page the Studio turn looked at is kept as the server stores it, and its request is not', () => {
const view = { spreadId: 'sp-3', bookVersion: 4, width: 1280, height: 640, bytes: 182_000, description: 'Page 2' };
const request = {
requestId: '5f0c3a52-8d1e-4b7a-9c2f-0a1b2c3d4e5f',
threadId: 9,
spreadId: 'sp-3',
bookVersion: 4,
hiddenPhotoIds: [],
};
const folded = fold(
[QUESTION],
[
{ type: 'start', messageId: 'm-2' },
{ type: 'data-trek-render-request', data: request, transient: true },
{ type: 'data-trek-page-view', data: view },
]
);
expect(folded[1]?.parts).toEqual([{ type: 'data-trek-page-view', data: view }]);
});
});
+3
View File
@@ -65,6 +65,9 @@ export function applyChunk(
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-weather', data: chunk.data }]);
case 'data-trek-card':
return chunk.data.card ? placeCard(messages, now, chunk.data) : messages;
case 'data-trek-page-view':
// A Studio page the turn looked at, as the server stores it: what it was, never the image.
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-page-view', data: chunk.data }]);
case 'finish':
return withStatus(messages, (m) =>
m.status === 'error' ? m.status : m.parts.some(isAwaitingDecision) ? 'awaiting_approval' : 'complete'
@@ -0,0 +1,152 @@
/**
* FE-AI-STORE-STUDIO-001 to FE-AI-STORE-STUDIO-005
*
* A journey's chat hosted by TREK Studio (journey-studio.md 3.1, 4.5): before
* a question or a decision goes out the Studio writes the book's local work,
* and the turn says the Studio draws pages; the page a turn asks for goes to
* the host with the turn's signal; an unhosted chat sends neither; a host
* that ends leaves a newer one in place; agreeing to page images is stored
* for the connection. The repos are faked, nothing reaches Dexie.
*/
import type { AiStatusResponse, AssistantPageRender, UiStreamChunk } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
import { assistantRepo } from '../repo/assistantRepo';
import { assistantChatKey } from './assistantChatKey';
import { type AssistantStudioHost, chatOf, useAssistantStore } from './assistantStore';
import { useSettingsStore } from './settingsStore';
const JOURNEY = 12;
const KEY = assistantChatKey({ kind: 'journey', journeyId: JOURNEY });
const WRITE_ID = 'c0ffee00-0000-4000-8000-000000000002';
const REQUEST: AssistantPageRender = {
requestId: '5f0c3a52-8d1e-4b7a-9c2f-0a1b2c3d4e5f',
threadId: 9,
spreadId: 'sp-3',
bookVersion: 4,
hiddenPhotoIds: [31],
};
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance',
canConnectOwn: false,
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
own: null,
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
quota: null,
privacyAcked: true,
studioImagesAcked: false,
retention: { mode: 'days', days: 90 },
openrouterOAuth: false,
};
async function* streamOf(chunks: UiStreamChunk[]): AsyncGenerator<UiStreamChunk> {
for (const chunk of chunks) yield chunk;
}
const store = () => useAssistantStore.getState();
/** A host whose calls are recorded in order, with the flush answering `landed`. */
function fakeHost(order: string[], landed = true) {
const render = vi.fn<AssistantStudioHost['render']>();
const flush = vi.fn(async () => {
order.push('flush');
return landed;
});
return { render, flush };
}
beforeEach(() => {
useAssistantStore.setState({ status: null, byTrip: {}, studioHosts: {}, lastApplied: null });
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'en' } }));
});
afterEach(() => vi.restoreAllMocks());
describe('assistantStore: a chat hosted by the Studio', () => {
it('FE-AI-STORE-STUDIO-001: writes the book before a question goes out and says the Studio draws pages', async () => {
const order: string[] = [];
const host = fakeHost(order);
store().hostStudio(KEY, host);
const send = vi.spyOn(assistantRepo, 'send').mockImplementationOnce(async () => {
order.push('send');
return streamOf([{ type: 'finish', finishReason: 'stop' }]);
});
await store().send(KEY, 'A quieter cover');
expect(order).toEqual(['flush', 'send']);
expect(send.mock.calls[0]?.[1]).toMatchObject({ text: 'A quieter cover', renders: true });
});
it('FE-AI-STORE-STUDIO-002: a flush that could not land still sends, and an unhosted chat sends no renders', async () => {
const host = fakeHost([], false);
const release = store().hostStudio(KEY, host);
const send = vi.spyOn(assistantRepo, 'send').mockImplementation(async () => streamOf([{ type: 'finish' }]));
await store().send(KEY, 'First');
release();
await store().send(KEY, 'Second');
expect(host.flush).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0]?.[1]).toMatchObject({ renders: true });
expect(send.mock.calls[1]?.[1]).not.toHaveProperty('renders');
});
it('FE-AI-STORE-STUDIO-003: hands the page a turn asks for to the host, with a signal that ends with the turn', async () => {
const host = fakeHost([]);
store().hostStudio(KEY, host);
vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(
streamOf([
{ type: 'start', messageId: 'msg-1' },
{ type: 'data-trek-render-request', data: REQUEST, transient: true },
{ type: 'finish', finishReason: 'stop' },
])
);
await store().send(KEY, 'Look at page 3');
expect(host.render).toHaveBeenCalledTimes(1);
const [request, signal] = host.render.mock.calls[0] ?? [];
expect(request).toEqual(REQUEST);
expect(signal).toBeInstanceOf(AbortSignal);
// The request is streamed only; the message keeps nothing of it.
expect(JSON.stringify(chatOf(store(), KEY).messages)).not.toContain(REQUEST.requestId);
});
it('FE-AI-STORE-STUDIO-004: writes the book before decisions go out, and a host that ended leaves a newer one', async () => {
const order: string[] = [];
const first = fakeHost([]);
const releaseFirst = store().hostStudio(KEY, first);
const second = fakeHost(order);
store().hostStudio(KEY, second);
releaseFirst();
expect(store().studioHosts[KEY]).toBe(second);
useAssistantStore.setState({ byTrip: { [KEY]: { ...chatOf(store(), KEY), threadId: 9 } } });
const decide = vi.spyOn(assistantRepo, 'decide').mockImplementationOnce(async () => {
order.push('decide');
return streamOf([{ type: 'finish', finishReason: 'stop' }]);
});
await store().decide(KEY, [{ toolCallId: WRITE_ID, approved: true }]);
expect(order).toEqual(['flush', 'decide']);
expect(first.flush).not.toHaveBeenCalled();
expect(decide.mock.calls[0]?.[2]).toMatchObject({ renders: true });
});
it('FE-AI-STORE-STUDIO-005: agreeing to page images is stored for the connection on screen', async () => {
useAssistantStore.setState({ status: STATUS });
const ack = vi.spyOn(aiConnectionsRepo, 'ackPrivacy').mockResolvedValueOnce(undefined);
await store().ackStudioImages();
expect(ack).toHaveBeenCalledWith({ kind: 'openai', host: 'api.openai.com', scope: 'studio_images' });
expect(store().status?.studioImagesAcked).toBe(true);
// The messages' own acknowledgement is a different one and stays as it was.
expect(store().status?.privacyAcked).toBe(true);
});
});
+33
View File
@@ -0,0 +1,33 @@
/* The assistant as a column of TREK Studio (journey-studio.md 4.5).
It takes the inspector's place at the end of the body; the inspector then
floats over the workbench beside it, as it does under 1280px, so the page
keeps the room it needs to stay legible. */
.trek-dash .st-body.has-assistant {
grid-template-columns: 64px 236px minmax(0, 1fr) 360px;
}
.trek-dash .st-body.has-assistant .st-inspector {
position: absolute;
top: 56px;
inset-inline-end: 360px;
bottom: 0;
width: 300px;
box-shadow: var(--sh-lg);
}
@media (max-width: 1079px) {
.trek-dash .st-body.has-assistant {
grid-template-columns: 64px minmax(0, 1fr) 360px;
}
}
.trek-dash .st-assistant {
min-width: 0;
overflow: hidden;
border-inline-start: 1px solid var(--glass-border);
}
.trek-dash .st-assistant-notice {
margin: 8px 12px 0;
}
@@ -0,0 +1,59 @@
import { bookPageSetupForPreset, bookPhotoElementSchema, type BookSpread } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { SpreadView } from '../../../src/components/Studio/SpreadView';
import { render } from '../../helpers/render';
/**
* FE-STUDIO-HIDE-001 to FE-STUDIO-HIDE-002
*
* A page drawn for the assistant shows only the asking user's own photos
* (journey-studio.md 3.4): a photo named as hidden is drawn as its empty
* frame, which stays on the page even in print, so the page still shows
* where a picture sits; an unfilled frame still vanishes in print; without
* the list every photo is drawn as before.
*/
const page = bookPageSetupForPreset('square-210');
function photo(id: string, photoId: number | null, x: number) {
return bookPhotoElementSchema.parse({ id, kind: 'photo', frame: { x, y: 10, w: 60, h: 40 }, photoId });
}
const spread: BookSpread = {
id: 'sp-3',
role: 'inner',
background: null,
elements: [photo('p-own', 7, 10), photo('p-theirs', 31, 100), photo('p-empty', null, 200)],
parked: [],
entryId: null,
};
/** The pictures the sheet shows. */
function pictures(container: HTMLElement): string[] {
return [...container.querySelectorAll('img')].map((img) => img.getAttribute('src') ?? '');
}
/** How many things the sheet draws. */
const drawnCount = (container: HTMLElement) => container.firstElementChild?.childElementCount ?? 0;
/** The spread holding one element alone. */
const alone = (index: number): BookSpread => ({ ...spread, elements: spread.elements.slice(index, index + 1) });
describe('SpreadView: photos left out', () => {
it('FE-STUDIO-HIDE-001: a hidden photo is an empty frame, kept in print, while an unfilled frame is not', () => {
const { container } = render(<SpreadView spread={spread} page={page} print hiddenPhotoIds={new Set([31])} />);
expect(pictures(container)).toEqual(['/api/photos/7/thumbnail']);
const hidden = render(<SpreadView spread={alone(1)} page={page} print hiddenPhotoIds={new Set([31])} />).container;
const unfilled = render(<SpreadView spread={alone(2)} page={page} print />).container;
expect(pictures(hidden)).toEqual([]);
expect(drawnCount(hidden)).toBe(drawnCount(unfilled) + 1);
});
it('FE-STUDIO-HIDE-002: without the list every photo is drawn as before', () => {
const { container } = render(<SpreadView spread={spread} page={page} print />);
expect(pictures(container)).toEqual(['/api/photos/7/thumbnail', '/api/photos/31/thumbnail']);
});
});
+7
View File
@@ -54,6 +54,7 @@
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"modern-screenshot": "4.7.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
@@ -18418,6 +18419,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/modern-screenshot": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.7.0.tgz",
"integrity": "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+8
View File
@@ -972,5 +972,13 @@ const journey: TranslationStrings = {
'الأيام المسجَّلة ومحطات التوقف المأخوذة منها، للأيام التي لها تدوينة منشورة. تُظهر أين كنت ومتى، بما في ذلك مكان إقامتك.',
'journey.share.needsMap': 'شارك الخريطة لتفعيل هذا',
'journey.editor.tags': 'الوسوم',
'journey.studio.assistant.toggle': 'المساعد',
'journey.studio.assistant.toggleHint': 'صمّم الكتاب مع المساعد',
'journey.studio.assistant.panel': 'مساعد الكتاب',
'journey.studio.assistant.welcome': 'كيف تريد أن يبدو الكتاب؟',
'journey.studio.assistant.notice':
'يمكن للمساعد رؤية الصفحة التي تعمل عليها. تُرسل هذه الصفحة وحدها إلى {provider} كصورة لا تظهر فيها إلا الصور التي أضفتها بنفسك. لا يمكن التعرّف على من يظهر في صورك.',
'journey.studio.assistant.noticeAllow': 'اعرض الصفحات',
'journey.studio.assistant.noticeLater': 'ليس الآن',
};
export default journey;
+8
View File
@@ -974,5 +974,13 @@ const journey: TranslationStrings = {
'Dərc olunmuş qeydi olan qeydə alınmış günlər və onlardan götürülmüş dayanacaqlar. Harada və nə vaxt olduğunu, qaldığın yeri də göstərir.',
'journey.share.needsMap': 'Bunu açmaq üçün xəritəni paylaş',
'journey.editor.tags': 'Etiketlər',
'journey.studio.assistant.toggle': 'Köməkçi',
'journey.studio.assistant.toggleHint': 'Kitabı köməkçi ilə birlikdə tərtib et',
'journey.studio.assistant.panel': 'Kitab köməkçisi',
'journey.studio.assistant.welcome': 'Kitab necə görünsün?',
'journey.studio.assistant.notice':
'Köməkçi üzərində işlədiyin səhifəyə baxa bilər. {provider} xidmətinə yalnız bu səhifə, yalnız özün əlavə etdiyin fotoların göründüyü şəkil kimi göndərilir. Fotolarında kimin olduğunu müəyyən etmək mümkün deyil.',
'journey.studio.assistant.noticeAllow': 'Səhifələri göstər',
'journey.studio.assistant.noticeLater': 'İndi yox',
};
export default journey;
+8
View File
@@ -904,5 +904,13 @@ const journey: TranslationStrings = {
'Dias gravados e as paradas tiradas deles, nos dias com um registro publicado. Mostram onde e quando você esteve, inclusive sua hospedagem.',
'journey.share.needsMap': 'Compartilhe o mapa para ativar isto',
'journey.editor.tags': 'Bomennoù',
'journey.studio.assistant.toggle': 'Assistente',
'journey.studio.assistant.toggleHint': 'Crie o livro junto com o assistente',
'journey.studio.assistant.panel': 'Assistente do livro',
'journey.studio.assistant.welcome': 'Como o livro deve ficar?',
'journey.studio.assistant.notice':
'O assistente pode olhar a página em que você está trabalhando. Só essa página vai para {provider}, como uma imagem que mostra apenas as fotos que você mesmo adicionou. Não é possível detectar quem aparece nas suas fotos.',
'journey.studio.assistant.noticeAllow': 'Mostrar páginas',
'journey.studio.assistant.noticeLater': 'Agora não',
};
export default journey;
+8
View File
@@ -906,5 +906,13 @@ const journey: TranslationStrings = {
"Dies enregistrats i les parades que se'n treuen, en els dies amb una entrada publicada. Mostren on i quan eres, també el teu allotjament.",
'journey.share.needsMap': 'Comparteix el mapa per activar-ho',
'journey.editor.tags': 'Etiquetes',
'journey.studio.assistant.toggle': 'Assistent',
'journey.studio.assistant.toggleHint': 'Dissenya el llibre amb l’assistent',
'journey.studio.assistant.panel': 'Assistent del llibre',
'journey.studio.assistant.welcome': 'Com hauria de ser el llibre?',
'journey.studio.assistant.notice':
'L’assistent pot mirar la pàgina en què treballes. Només aquesta pàgina s’envia a {provider}, com una imatge que mostra només les fotos que has afegit tu. No es pot detectar qui surt a les teves fotos.',
'journey.studio.assistant.noticeAllow': 'Mostra-li pàgines',
'journey.studio.assistant.noticeLater': 'Ara no',
};
export default journey;
+8
View File
@@ -916,5 +916,13 @@ const journey: TranslationStrings = {
'Zaznamenané dny a zastávky z nich převzaté, u dnů se zveřejněným záznamem. Ukazují, kde a kdy jste byli, i vaše ubytování.',
'journey.share.needsMap': 'Pro zapnutí sdílejte mapu',
'journey.editor.tags': 'Štítky',
'journey.studio.assistant.toggle': 'Asistent',
'journey.studio.assistant.toggleHint': 'Navrhněte knihu společně s asistentem',
'journey.studio.assistant.panel': 'Asistent knihy',
'journey.studio.assistant.welcome': 'Jak má kniha vypadat?',
'journey.studio.assistant.notice':
'Asistent se může podívat na stránku, na které pracujete. Na {provider} jde jen tato stránka, jako obrázek, na kterém jsou jen fotky, které jste přidali sami. Kdo je na vašich fotkách, nelze rozpoznat.',
'journey.studio.assistant.noticeAllow': 'Ukázat stránky',
'journey.studio.assistant.noticeLater': 'Teď ne',
};
export default journey;
+8
View File
@@ -912,5 +912,13 @@ const journey: TranslationStrings = {
'Aufgezeichnete Tage und die daraus übernommenen Aufenthalte, an Tagen mit veröffentlichtem Eintrag. Sie zeigen, wo und wann du warst, auch deine Unterkunft.',
'journey.share.needsMap': 'Teile die Karte, um das einzuschalten',
'journey.editor.tags': 'Tags', // same-as-en
'journey.studio.assistant.toggle': 'Assistent',
'journey.studio.assistant.toggleHint': 'Das Buch gemeinsam mit dem Assistenten gestalten',
'journey.studio.assistant.panel': 'Buch-Assistent',
'journey.studio.assistant.welcome': 'Wie soll das Buch aussehen?',
'journey.studio.assistant.notice':
'Der Assistent kann sich die Seite ansehen, an der du arbeitest. Nur diese Seite geht an {provider}, als Bild, auf dem nur deine eigenen Fotos zu sehen sind. Wer auf deinen Fotos ist, lässt sich nicht erkennen.',
'journey.studio.assistant.noticeAllow': 'Seiten zeigen',
'journey.studio.assistant.noticeLater': 'Jetzt nicht',
};
export default journey;
+8
View File
@@ -892,5 +892,13 @@ const journey: TranslationStrings = {
'Recorded days and the stays taken from them, on days with a published entry. They show where and when you were, your accommodation too.',
'journey.share.needsMap': 'Share the map to switch this on',
'journey.editor.tags': 'Tags',
'journey.studio.assistant.toggle': 'Assistant',
'journey.studio.assistant.toggleHint': 'Design the book together with the assistant',
'journey.studio.assistant.panel': 'Book assistant',
'journey.studio.assistant.welcome': 'What should the book look like?',
'journey.studio.assistant.notice':
'The assistant can look at the page you are working on. Only that page goes to {provider}, as a picture showing just the photos you added yourself. Who is in your photos cannot be detected.',
'journey.studio.assistant.noticeAllow': 'Show it pages',
'journey.studio.assistant.noticeLater': 'Not now',
};
export default journey;
+8
View File
@@ -906,5 +906,13 @@ const journey: TranslationStrings = {
'Días grabados y las paradas tomadas de ellos, en los días con una entrada publicada. Muestran dónde y cuándo estuviste, también tu alojamiento.',
'journey.share.needsMap': 'Comparte el mapa para activarlo',
'journey.editor.tags': 'Etiquetas',
'journey.studio.assistant.toggle': 'Asistente',
'journey.studio.assistant.toggleHint': 'Diseña el libro junto con el asistente',
'journey.studio.assistant.panel': 'Asistente del libro',
'journey.studio.assistant.welcome': '¿Cómo debería verse el libro?',
'journey.studio.assistant.notice':
'El asistente puede ver la página en la que estás trabajando. Solo esa página se envía a {provider}, como una imagen que muestra únicamente las fotos que añadiste tú. No se puede detectar quién aparece en tus fotos.',
'journey.studio.assistant.noticeAllow': 'Mostrarle páginas',
'journey.studio.assistant.noticeLater': 'Ahora no',
};
export default journey;
+8
View File
@@ -896,5 +896,13 @@ const journey: TranslationStrings = {
'Salvestatud päevad ja neist võetud peatused päevadel, millel on avaldatud sissekanne. Need näitavad, kus ja millal sa olid, ka su majutust.',
'journey.share.needsMap': 'Selle sisselülitamiseks jaga kaarti',
'journey.editor.tags': 'Sildid',
'journey.studio.assistant.toggle': 'Assistent',
'journey.studio.assistant.toggleHint': 'Kujunda raamat koos assistendiga',
'journey.studio.assistant.panel': 'Raamatu assistent',
'journey.studio.assistant.welcome': 'Milline peaks raamat välja nägema?',
'journey.studio.assistant.notice':
'Assistent saab vaadata lehte, mille kallal töötad. {provider} saab ainult selle lehe, pildina, millel on näha ainult sinu enda lisatud fotod. Seda, kes sinu fotodel on, ei saa tuvastada.',
'journey.studio.assistant.noticeAllow': 'Näita lehti',
'journey.studio.assistant.noticeLater': 'Mitte praegu',
};
export default journey;
+8
View File
@@ -904,5 +904,13 @@ const journey: TranslationStrings = {
'Jours enregistrés et les arrêts qui en sont tirés, les jours ayant un récit publié. Ils montrent où et quand vous étiez, y compris votre hébergement.',
'journey.share.needsMap': 'Partagez la carte pour activer ceci',
'journey.editor.tags': 'Étiquettes',
'journey.studio.assistant.toggle': 'Assistant', // same-as-en
'journey.studio.assistant.toggleHint': 'Concevoir le livre avec l’assistant',
'journey.studio.assistant.panel': 'Assistant du livre',
'journey.studio.assistant.welcome': 'À quoi doit ressembler le livre ?',
'journey.studio.assistant.notice':
'L’assistant peut regarder la page sur laquelle vous travaillez. Seule cette page est envoyée à {provider}, sous forme d’image ne montrant que les photos que vous avez ajoutées vous-même. Il est impossible de détecter qui figure sur vos photos.',
'journey.studio.assistant.noticeAllow': 'Lui montrer les pages',
'journey.studio.assistant.noticeLater': 'Pas maintenant',
};
export default journey;
+8
View File
@@ -914,5 +914,13 @@ const journey: TranslationStrings = {
'Καταγεγραμμένες μέρες και οι στάσεις που προέρχονται από αυτές, σε μέρες με δημοσιευμένη καταχώριση. Δείχνουν πού και πότε ήσουν, και το κατάλυμά σου.',
'journey.share.needsMap': 'Μοιράσου τον χάρτη για να το ενεργοποιήσεις',
'journey.editor.tags': 'Ετικέτες',
'journey.studio.assistant.toggle': 'Βοηθός',
'journey.studio.assistant.toggleHint': 'Σχεδιάστε το βιβλίο μαζί με τον βοηθό',
'journey.studio.assistant.panel': 'Βοηθός βιβλίου',
'journey.studio.assistant.welcome': 'Πώς πρέπει να είναι το βιβλίο;',
'journey.studio.assistant.notice':
'Ο βοηθός μπορεί να δει τη σελίδα στην οποία δουλεύετε. Μόνο αυτή η σελίδα στέλνεται στο {provider}, ως εικόνα που δείχνει μόνο τις φωτογραφίες που προσθέσατε εσείς. Δεν είναι δυνατό να αναγνωριστεί ποιος εμφανίζεται στις φωτογραφίες σας.',
'journey.studio.assistant.noticeAllow': 'Εμφάνιση σελίδων',
'journey.studio.assistant.noticeLater': 'Όχι τώρα',
};
export default journey;
+8
View File
@@ -902,5 +902,13 @@ const journey: TranslationStrings = {
'Rögzített napok és a belőlük átvett megállók azokon a napokon, amelyekhez közzétett bejegyzés tartozik. Megmutatják, hol és mikor jártál, a szállásodat is.',
'journey.share.needsMap': 'Oszd meg a térképet a bekapcsoláshoz',
'journey.editor.tags': 'Címkék',
'journey.studio.assistant.toggle': 'Asszisztens',
'journey.studio.assistant.toggleHint': 'Tervezd meg a könyvet az asszisztenssel',
'journey.studio.assistant.panel': 'Könyvasszisztens',
'journey.studio.assistant.welcome': 'Milyen legyen a könyv?',
'journey.studio.assistant.notice':
'Az asszisztens megnézheti az oldalt, amelyen dolgozol. Csak ez az oldal kerül a(z) {provider} szolgáltatóhoz, képként, amelyen csak a saját magad által hozzáadott fotók látszanak. Azt, hogy ki szerepel a fotóidon, nem lehet felismerni.',
'journey.studio.assistant.noticeAllow': 'Oldalak megmutatása',
'journey.studio.assistant.noticeLater': 'Most nem',
};
export default journey;
+8
View File
@@ -878,5 +878,13 @@ const journey: TranslationStrings = {
'Hari terekam dan persinggahan yang diambil darinya, pada hari yang punya catatan terbit. Menunjukkan di mana dan kapan kamu berada, termasuk penginapanmu.',
'journey.share.needsMap': 'Bagikan peta untuk mengaktifkan ini',
'journey.editor.tags': 'Tag',
'journey.studio.assistant.toggle': 'Asisten',
'journey.studio.assistant.toggleHint': 'Rancang buku bersama asisten',
'journey.studio.assistant.panel': 'Asisten buku',
'journey.studio.assistant.welcome': 'Seperti apa bukunya nanti?',
'journey.studio.assistant.notice':
'Asisten dapat melihat halaman yang sedang Anda kerjakan. Hanya halaman itu yang dikirim ke {provider}, sebagai gambar yang hanya menampilkan foto yang Anda tambahkan sendiri. Siapa yang ada di foto Anda tidak dapat dideteksi.',
'journey.studio.assistant.noticeAllow': 'Tunjukkan halaman',
'journey.studio.assistant.noticeLater': 'Nanti saja',
};
export default journey;
+8
View File
@@ -905,5 +905,13 @@ const journey: TranslationStrings = {
'Giorni registrati e le soste ricavate da essi, nei giorni con una voce pubblicata. Mostrano dove e quando sei stato, anche il tuo alloggio.',
'journey.share.needsMap': 'Condividi la mappa per attivarlo',
'journey.editor.tags': 'Tag',
'journey.studio.assistant.toggle': 'Assistente',
'journey.studio.assistant.toggleHint': 'Progetta il libro insieme all’assistente',
'journey.studio.assistant.panel': 'Assistente del libro',
'journey.studio.assistant.welcome': 'Come dovrebbe essere il libro?',
'journey.studio.assistant.notice':
'L’assistente può guardare la pagina su cui stai lavorando. Solo quella pagina viene inviata a {provider}, come immagine che mostra soltanto le foto che hai aggiunto tu. Non è possibile riconoscere chi compare nelle tue foto.',
'journey.studio.assistant.noticeAllow': 'Mostra le pagine',
'journey.studio.assistant.noticeLater': 'Non ora',
};
export default journey;
+8
View File
@@ -864,5 +864,13 @@ const journey: TranslationStrings = {
'公開済みのエントリーがある日の記録と、そこから取り込んだ滞在。宿泊先も含め、いつどこにいたかがわかります。',
'journey.share.needsMap': '有効にするには地図を共有してください',
'journey.editor.tags': 'タグ',
'journey.studio.assistant.toggle': 'アシスタント',
'journey.studio.assistant.toggleHint': 'アシスタントと一緒に本をデザイン',
'journey.studio.assistant.panel': '本のアシスタント',
'journey.studio.assistant.welcome': 'どんな本にしますか?',
'journey.studio.assistant.notice':
'アシスタントは作業中のページを見ることができます。{provider} に送られるのはそのページだけで、あなた自身が追加した写真だけが写った画像として送られます。写真に誰が写っているかは判別できません。',
'journey.studio.assistant.noticeAllow': 'ページを見せる',
'journey.studio.assistant.noticeLater': '今はしない',
};
export default journey;
+8
View File
@@ -860,5 +860,13 @@ const journey: TranslationStrings = {
'게시된 항목이 있는 날의 기록과 거기서 가져온 머문 곳. 숙소를 포함해 언제 어디에 있었는지 보여줍니다.',
'journey.share.needsMap': '이 기능을 켜려면 지도를 공유하세요',
'journey.editor.tags': '태그',
'journey.studio.assistant.toggle': '어시스턴트',
'journey.studio.assistant.toggleHint': '어시스턴트와 함께 책 디자인하기',
'journey.studio.assistant.panel': '책 어시스턴트',
'journey.studio.assistant.welcome': '책을 어떻게 꾸밀까요?',
'journey.studio.assistant.notice':
'어시스턴트가 작업 중인 페이지를 볼 수 있습니다. {provider}(으)로는 그 페이지만, 직접 추가한 사진만 보이는 이미지로 전송됩니다. 사진에 누가 있는지는 감지할 수 없습니다.',
'journey.studio.assistant.noticeAllow': '페이지 보여주기',
'journey.studio.assistant.noticeLater': '나중에',
};
export default journey;
+8
View File
@@ -906,5 +906,13 @@ const journey: TranslationStrings = {
'Opgenomen dagen en de stops die eruit zijn overgenomen, op dagen met een gepubliceerd verhaal. Ze tonen waar en wanneer je was, ook je verblijf.',
'journey.share.needsMap': 'Deel de kaart om dit aan te zetten',
'journey.editor.tags': 'Tags', // same-as-en
'journey.studio.assistant.toggle': 'Assistent',
'journey.studio.assistant.toggleHint': 'Ontwerp het boek samen met de assistent',
'journey.studio.assistant.panel': 'Boekassistent',
'journey.studio.assistant.welcome': 'Hoe moet het boek eruitzien?',
'journey.studio.assistant.notice':
'De assistent kan de pagina bekijken waaraan je werkt. Alleen die pagina gaat naar {provider}, als afbeelding met alleen de foto’s die je zelf hebt toegevoegd. Wie er op je foto’s staat, kan niet worden herkend.',
'journey.studio.assistant.noticeAllow': 'Pagina’s tonen',
'journey.studio.assistant.noticeLater': 'Niet nu',
};
export default journey;
+8
View File
@@ -947,5 +947,13 @@ const journey: TranslationStrings = {
'Nagrane dni i przejęte z nich postoje, w dniach z opublikowanym wpisem. Pokazują, gdzie i kiedy byłeś, także nocleg.',
'journey.share.needsMap': 'Udostępnij mapę, aby to włączyć',
'journey.editor.tags': 'Tagi',
'journey.studio.assistant.toggle': 'Asystent',
'journey.studio.assistant.toggleHint': 'Projektuj książkę razem z asystentem',
'journey.studio.assistant.panel': 'Asystent książki',
'journey.studio.assistant.welcome': 'Jak ma wyglądać książka?',
'journey.studio.assistant.notice':
'Asystent może zobaczyć stronę, nad którą pracujesz. Do {provider} trafia tylko ta strona, jako obraz pokazujący wyłącznie zdjęcia dodane przez ciebie. Nie da się rozpoznać, kto jest na twoich zdjęciach.',
'journey.studio.assistant.noticeAllow': 'Pokaż strony',
'journey.studio.assistant.noticeLater': 'Nie teraz',
};
export default journey;
+8
View File
@@ -946,5 +946,13 @@ const journey: TranslationStrings = {
'Записанные дни и взятые из них остановки, в дни с опубликованной записью. Они показывают, где и когда вы были, в том числе ваше жильё.',
'journey.share.needsMap': 'Откройте доступ к карте, чтобы включить это',
'journey.editor.tags': 'Теги',
'journey.studio.assistant.toggle': 'Ассистент',
'journey.studio.assistant.toggleHint': 'Создавайте книгу вместе с ассистентом',
'journey.studio.assistant.panel': 'Ассистент книги',
'journey.studio.assistant.welcome': 'Какой должна быть книга?',
'journey.studio.assistant.notice':
'Ассистент может посмотреть страницу, над которой вы работаете. В {provider} отправляется только эта страница, в виде изображения, на котором видны лишь фото, добавленные вами. Определить, кто изображён на ваших фото, невозможно.',
'journey.studio.assistant.noticeAllow': 'Показывать страницы',
'journey.studio.assistant.noticeLater': 'Не сейчас',
};
export default journey;
+8
View File
@@ -922,5 +922,13 @@ const journey: TranslationStrings = {
'Zaznamenané dni a zastávky z nich prevzaté, v dňoch so zverejneným záznamom. Ukazujú, kde a kedy ste boli, aj vaše ubytovanie.',
'journey.share.needsMap': 'Ak to chcete zapnúť, zdieľajte mapu',
'journey.editor.tags': 'Štítky',
'journey.studio.assistant.toggle': 'Asistent',
'journey.studio.assistant.toggleHint': 'Navrhnite knihu spolu s asistentom',
'journey.studio.assistant.panel': 'Asistent knihy',
'journey.studio.assistant.welcome': 'Ako má kniha vyzerať?',
'journey.studio.assistant.notice':
'Asistent sa môže pozrieť na stranu, na ktorej pracujete. Do {provider} ide iba táto strana, ako obrázok, na ktorom sú len fotky, ktoré ste pridali sami. Kto je na vašich fotkách, sa nedá rozpoznať.',
'journey.studio.assistant.noticeAllow': 'Ukázať strany',
'journey.studio.assistant.noticeLater': 'Teraz nie',
};
export default journey;
+8
View File
@@ -896,5 +896,13 @@ const journey: TranslationStrings = {
'Inspelade dagar och de stopp som hämtats från dem, på dagar med ett publicerat inlägg. De visar var och när du var, även ditt boende.',
'journey.share.needsMap': 'Dela kartan för att slå på detta',
'journey.editor.tags': 'Taggar',
'journey.studio.assistant.toggle': 'Assistent',
'journey.studio.assistant.toggleHint': 'Formge boken tillsammans med assistenten',
'journey.studio.assistant.panel': 'Bokassistent',
'journey.studio.assistant.welcome': 'Hur ska boken se ut?',
'journey.studio.assistant.notice':
'Assistenten kan titta på sidan du arbetar med. Bara den sidan skickas till {provider}, som en bild som bara visar foton du själv har lagt till. Vem som finns på dina foton går inte att upptäcka.',
'journey.studio.assistant.noticeAllow': 'Visa sidor',
'journey.studio.assistant.noticeLater': 'Inte nu',
};
export default journey;
+8
View File
@@ -853,5 +853,13 @@ const journey: TranslationStrings = {
'วันที่บันทึกและจุดแวะที่นำมาจากวันนั้น ในวันที่มีบันทึกที่เผยแพร่แล้ว แสดงว่าคุณอยู่ที่ไหนและเมื่อไร รวมถึงที่พักด้วย',
'journey.share.needsMap': 'แชร์แผนที่เพื่อเปิดใช้งานนี้',
'journey.editor.tags': 'แท็ก',
'journey.studio.assistant.toggle': 'ผู้ช่วย',
'journey.studio.assistant.toggleHint': 'ออกแบบหนังสือร่วมกับผู้ช่วย',
'journey.studio.assistant.panel': 'ผู้ช่วยหนังสือ',
'journey.studio.assistant.welcome': 'อยากให้หนังสือออกมาเป็นแบบไหน?',
'journey.studio.assistant.notice':
'ผู้ช่วยสามารถดูหน้าที่คุณกำลังทำอยู่ได้ มีเพียงหน้านั้นที่ส่งไปยัง {provider} ในรูปแบบภาพที่แสดงเฉพาะรูปภาพที่คุณเพิ่มเอง ไม่สามารถตรวจจับได้ว่าใครอยู่ในรูปภาพของคุณ',
'journey.studio.assistant.noticeAllow': 'แสดงหน้าให้ดู',
'journey.studio.assistant.noticeLater': 'ไว้ทีหลัง',
};
export default journey;
+8
View File
@@ -894,5 +894,13 @@ const journey: TranslationStrings = {
'Yayınlanmış bir girdisi olan günlerde kaydedilmiş günler ve onlardan alınan duraklar. Nerede ve ne zaman olduğunu, konakladığın yeri de gösterir.',
'journey.share.needsMap': 'Bunu açmak için haritayı paylaş',
'journey.editor.tags': 'Etiketler',
'journey.studio.assistant.toggle': 'Asistan',
'journey.studio.assistant.toggleHint': 'Kitabı asistanla birlikte tasarla',
'journey.studio.assistant.panel': 'Kitap asistanı',
'journey.studio.assistant.welcome': 'Kitap nasıl görünsün?',
'journey.studio.assistant.notice':
'Asistan üzerinde çalıştığın sayfaya bakabilir. {provider} hizmetine yalnızca bu sayfa, sadece kendi eklediğin fotoğrafları gösteren bir görüntü olarak gider. Fotoğraflarında kimin olduğu tespit edilemez.',
'journey.studio.assistant.noticeAllow': 'Sayfaları göster',
'journey.studio.assistant.noticeLater': 'Şimdi değil',
};
export default journey;
+8
View File
@@ -953,5 +953,13 @@ const journey: TranslationStrings = {
'Записані дні та взяті з них зупинки, у дні з опублікованим записом. Вони показують, де і коли ви були, зокрема ваше житло.',
'journey.share.needsMap': 'Відкрийте доступ до карти, щоб увімкнути це',
'journey.editor.tags': 'Теги',
'journey.studio.assistant.toggle': 'Асистент',
'journey.studio.assistant.toggleHint': 'Створюйте книгу разом з асистентом',
'journey.studio.assistant.panel': 'Асистент книги',
'journey.studio.assistant.welcome': 'Якою має бути книга?',
'journey.studio.assistant.notice':
'Асистент може переглянути сторінку, над якою ви працюєте. До {provider} надсилається лише ця сторінка, як зображення, на якому видно тільки фото, додані вами. Визначити, хто є на ваших фото, неможливо.',
'journey.studio.assistant.noticeAllow': 'Показувати сторінки',
'journey.studio.assistant.noticeLater': 'Не зараз',
};
export default journey;
+8
View File
@@ -873,5 +873,13 @@ const journey: TranslationStrings = {
'Những ngày đã ghi và các điểm dừng lấy từ đó, vào những ngày có bài viết được đăng. Cho thấy bạn đã ở đâu và khi nào, kể cả chỗ ở.',
'journey.share.needsMap': 'Chia sẻ bản đồ để bật mục này',
'journey.editor.tags': 'Thẻ',
'journey.studio.assistant.toggle': 'Trợ lý',
'journey.studio.assistant.toggleHint': 'Thiết kế cuốn sách cùng trợ lý',
'journey.studio.assistant.panel': 'Trợ lý sách',
'journey.studio.assistant.welcome': 'Cuốn sách nên trông như thế nào?',
'journey.studio.assistant.notice':
'Trợ lý có thể xem trang bạn đang làm. Chỉ trang đó được gửi tới {provider}, dưới dạng ảnh chỉ hiển thị những ảnh do chính bạn thêm vào. Không thể nhận biết ai có trong ảnh của bạn.',
'journey.studio.assistant.noticeAllow': 'Cho xem trang',
'journey.studio.assistant.noticeLater': 'Để sau',
};
export default journey;
+8
View File
@@ -840,5 +840,13 @@ const journey: TranslationStrings = {
'journey.share.recordingsHint': '有已發布日記的日子的記錄,以及從中取用的停留點。會顯示你何時去過哪裡,也包括住處。',
'journey.share.needsMap': '分享地圖後才能開啟',
'journey.editor.tags': '標籤',
'journey.studio.assistant.toggle': '助理',
'journey.studio.assistant.toggleHint': '與助理一起設計這本書',
'journey.studio.assistant.panel': '書籍助理',
'journey.studio.assistant.welcome': '這本書想做成什麼樣子?',
'journey.studio.assistant.notice':
'助理可以查看你正在編輯的頁面。只有這一頁會以圖片形式傳送給 {provider},圖片中只顯示你自己新增的照片。無法辨識照片中的人物是誰。',
'journey.studio.assistant.noticeAllow': '允許查看頁面',
'journey.studio.assistant.noticeLater': '暫不',
};
export default journey;
+8
View File
@@ -840,5 +840,13 @@ const journey: TranslationStrings = {
'journey.share.recordingsHint': '有已发布日记的日子的记录,以及从中取用的停留点。会显示你何时去过哪里,也包括住处。',
'journey.share.needsMap': '共享地图后才能开启',
'journey.editor.tags': '标签',
'journey.studio.assistant.toggle': '助手',
'journey.studio.assistant.toggleHint': '与助手一起设计这本书',
'journey.studio.assistant.panel': '书籍助手',
'journey.studio.assistant.welcome': '这本书想做成什么样子?',
'journey.studio.assistant.notice':
'助手可以查看你正在编辑的页面。只有这一页会以图片形式发送给 {provider},图片中只显示你自己添加的照片。无法识别照片中的人物是谁。',
'journey.studio.assistant.noticeAllow': '允许查看页面',
'journey.studio.assistant.noticeLater': '暂不',
};
export default journey;