mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user