From 09ffc7d9465ba67610645dadac42088ffcff6677 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 21:25:46 +0000 Subject: [PATCH] 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. --- client/e2e/page-snapshot-webkit.spec.ts | 14 + client/e2e/page-snapshot.spec.ts | 10 + client/e2e/support/pageSnapshotFlow.ts | 34 +++ client/e2e/support/pageSnapshotHarness.ts | 91 ++++++ client/package.json | 1 + .../components/Assistant/AssistantWelcome.tsx | 12 +- client/src/components/Studio/SpreadView.tsx | 28 +- .../Studio/StudioAssistantPanel.test.tsx | 108 +++++++ .../Studio/StudioAssistantPanel.tsx | 173 +++++++++++ client/src/components/Studio/StudioShell.tsx | 42 ++- .../components/Studio/pageSnapshot.test.ts | 114 ++++++++ client/src/components/Studio/pageSnapshot.ts | 146 ++++++++++ .../Studio/useStudioAssistant.test.tsx | 238 ++++++++++++++++ .../components/Studio/useStudioAssistant.ts | 269 ++++++++++++++++++ client/src/store/assistantMessages.test.ts | 24 +- client/src/store/assistantMessages.ts | 3 + .../src/store/assistantStore.studio.test.ts | 152 ++++++++++ client/src/styles/studio-assistant.css | 33 +++ .../tests/unit/studio/hiddenPhotos.test.tsx | 59 ++++ package-lock.json | 7 + shared/src/i18n/ar/journey.ts | 8 + shared/src/i18n/az/journey.ts | 8 + shared/src/i18n/br/journey.ts | 8 + shared/src/i18n/ca/journey.ts | 8 + shared/src/i18n/cs/journey.ts | 8 + shared/src/i18n/de/journey.ts | 8 + shared/src/i18n/en/journey.ts | 8 + shared/src/i18n/es/journey.ts | 8 + shared/src/i18n/et/journey.ts | 8 + shared/src/i18n/fr/journey.ts | 8 + shared/src/i18n/gr/journey.ts | 8 + shared/src/i18n/hu/journey.ts | 8 + shared/src/i18n/id/journey.ts | 8 + shared/src/i18n/it/journey.ts | 8 + shared/src/i18n/ja/journey.ts | 8 + shared/src/i18n/ko/journey.ts | 8 + shared/src/i18n/nl/journey.ts | 8 + shared/src/i18n/pl/journey.ts | 8 + shared/src/i18n/ru/journey.ts | 8 + shared/src/i18n/sk/journey.ts | 8 + shared/src/i18n/sv/journey.ts | 8 + shared/src/i18n/th/journey.ts | 8 + shared/src/i18n/tr/journey.ts | 8 + shared/src/i18n/uk/journey.ts | 8 + shared/src/i18n/vi/journey.ts | 8 + shared/src/i18n/zh-TW/journey.ts | 8 + shared/src/i18n/zh/journey.ts | 8 + 47 files changed, 1763 insertions(+), 11 deletions(-) create mode 100644 client/e2e/page-snapshot-webkit.spec.ts create mode 100644 client/e2e/page-snapshot.spec.ts create mode 100644 client/e2e/support/pageSnapshotFlow.ts create mode 100644 client/e2e/support/pageSnapshotHarness.ts create mode 100644 client/src/components/Studio/StudioAssistantPanel.test.tsx create mode 100644 client/src/components/Studio/StudioAssistantPanel.tsx create mode 100644 client/src/components/Studio/pageSnapshot.test.ts create mode 100644 client/src/components/Studio/pageSnapshot.ts create mode 100644 client/src/components/Studio/useStudioAssistant.test.tsx create mode 100644 client/src/components/Studio/useStudioAssistant.ts create mode 100644 client/src/store/assistantStore.studio.test.ts create mode 100644 client/src/styles/studio-assistant.css create mode 100644 client/tests/unit/studio/hiddenPhotos.test.tsx diff --git a/client/e2e/page-snapshot-webkit.spec.ts b/client/e2e/page-snapshot-webkit.spec.ts new file mode 100644 index 000000000..61f0819a2 --- /dev/null +++ b/client/e2e/page-snapshot-webkit.spec.ts @@ -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); +}); diff --git a/client/e2e/page-snapshot.spec.ts b/client/e2e/page-snapshot.spec.ts new file mode 100644 index 000000000..5a10224d5 --- /dev/null +++ b/client/e2e/page-snapshot.spec.ts @@ -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); +}); diff --git a/client/e2e/support/pageSnapshotFlow.ts b/client/e2e/support/pageSnapshotFlow.ts new file mode 100644 index 000000000..0a876db54 --- /dev/null +++ b/client/e2e/support/pageSnapshotFlow.ts @@ -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 { + await page.goto('/dashboard'); + const shot = await page.evaluate(async (harness: string): Promise => { + const mod = (await import(/* @vite-ignore */ harness)) as { + snapshotSeededSpread: () => Promise; + }; + 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); +} diff --git a/client/e2e/support/pageSnapshotHarness.ts b/client/e2e/support/pageSnapshotHarness.ts new file mode 100644 index 000000000..63bc0e469 --- /dev/null +++ b/client/e2e/support/pageSnapshotHarness.ts @@ -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 { + 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(); + } +} diff --git a/client/package.json b/client/package.json index bd34b3cb2..246dc4d17 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/Assistant/AssistantWelcome.tsx b/client/src/components/Assistant/AssistantWelcome.tsx index ada9c5e75..b42bc4b61 100644 --- a/client/src/components/Assistant/AssistantWelcome.tsx +++ b/client/src/components/Assistant/AssistantWelcome.tsx @@ -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 (
- + {!canEdit && (

{t('assistant.empty.readOnly')}

)} diff --git a/client/src/components/Studio/SpreadView.tsx b/client/src/components/Studio/SpreadView.tsx index 4743f99c6..fa3f4d8b9 100644 --- a/client/src/components/Studio/SpreadView.tsx +++ b/client/src/components/Studio/SpreadView.tsx @@ -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 +}) { 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 + } return } @@ -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 }) { const isSingle = spread.role !== 'inner' const w = isSingle ? page.pageWidth : page.pageWidth * 2 @@ -468,7 +483,14 @@ export function SpreadView({ }} > {spread.elements.map(el => ( - + ))} diff --git a/client/src/components/Studio/StudioAssistantPanel.test.tsx b/client/src/components/Studio/StudioAssistantPanel.test.tsx new file mode 100644 index 000000000..4d084beaa --- /dev/null +++ b/client/src/components/Studio/StudioAssistantPanel.test.tsx @@ -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(); + + 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(); + + 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(); + 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(); + + 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); + }); +}); diff --git a/client/src/components/Studio/StudioAssistantPanel.tsx b/client/src/components/Studio/StudioAssistantPanel.tsx new file mode 100644 index 000000000..ba8f75260 --- /dev/null +++ b/client/src/components/Studio/StudioAssistantPanel.tsx @@ -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 = { 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 ( +
+
+
+
+ + +
+
+ ); +} + +/** 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; +}) { + const sheet = useRef(null); + // A job goes up once, even when the effect runs again (development's double run, a new callback). + const sent = useRef(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. +
+ +
+ ); +} + +/** + * 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 ( + + ); +} + +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) => { + e.stopPropagation(); + if (e.key === 'Escape' && isRunning && !e.defaultPrevented) stop(); + }; + + return ( + + ); +} diff --git a/client/src/components/Studio/StudioShell.tsx b/client/src/components/Studio/StudioShell.tsx index c95f0323f..5b7a0633b 100644 --- a/client/src/components/Studio/StudioShell.tsx +++ b/client/src/components/Studio/StudioShell.tsx @@ -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')} > - setExporting(true)} /> + setExporting(true)} + assistant={assistant} + /> {/* The journey gallery's picker, inside Studio's own layer (#2271). */} {s.providerPick && s.journey && ( )} -
+
+
+ {exporting && s.doc && ( +/** 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 +} + 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 (
+ )} + + + + ); +} + +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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(, { initialEntries: ['/journey/12/studio?page=sp-4'] }); + + await waitFor(() => expect(useStudioStore.getState().activeSpread).toBe(3)); + await waitFor(() => expect(screen.getByTestId('search').textContent).toBe('')); + }); +}); diff --git a/client/src/components/Studio/useStudioAssistant.ts b/client/src/components/Studio/useStudioAssistant.ts new file mode 100644 index 000000000..dc245630b --- /dev/null +++ b/client/src/components/Studio/useStudioAssistant.ts @@ -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=`, 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; + /** 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 { + 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 { + 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([]); + + 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=`, 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 }) { + 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; diff --git a/client/src/store/assistantMessages.test.ts b/client/src/store/assistantMessages.test.ts index e90dc33cc..2e6eb6adc 100644 --- a/client/src/store/assistantMessages.test.ts +++ b/client/src/store/assistantMessages.test.ts @@ -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 }]); + }); }); diff --git a/client/src/store/assistantMessages.ts b/client/src/store/assistantMessages.ts index b324dc178..3da95ef35 100644 --- a/client/src/store/assistantMessages.ts +++ b/client/src/store/assistantMessages.ts @@ -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' diff --git a/client/src/store/assistantStore.studio.test.ts b/client/src/store/assistantStore.studio.test.ts new file mode 100644 index 000000000..7414904d9 --- /dev/null +++ b/client/src/store/assistantStore.studio.test.ts @@ -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 { + 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(); + 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); + }); +}); diff --git a/client/src/styles/studio-assistant.css b/client/src/styles/studio-assistant.css new file mode 100644 index 000000000..c4e30c71d --- /dev/null +++ b/client/src/styles/studio-assistant.css @@ -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; +} diff --git a/client/tests/unit/studio/hiddenPhotos.test.tsx b/client/tests/unit/studio/hiddenPhotos.test.tsx new file mode 100644 index 000000000..04cfaa3d7 --- /dev/null +++ b/client/tests/unit/studio/hiddenPhotos.test.tsx @@ -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(); + expect(pictures(container)).toEqual(['/api/photos/7/thumbnail']); + + const hidden = render().container; + const unfilled = render().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(); + + expect(pictures(container)).toEqual(['/api/photos/7/thumbnail', '/api/photos/31/thumbnail']); + }); +}); diff --git a/package-lock.json b/package-lock.json index b80b9beed..4f8cde99a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 1150a26af..1828b4508 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -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; diff --git a/shared/src/i18n/az/journey.ts b/shared/src/i18n/az/journey.ts index 0636a7d08..96ccfef4a 100644 --- a/shared/src/i18n/az/journey.ts +++ b/shared/src/i18n/az/journey.ts @@ -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; diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index 712bd63e8..819ba314c 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -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; diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index 40c1da3e1..56826b172 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -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; diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index f119de131..2738b9e47 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -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; diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index 82838f1ee..270cae8ee 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -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; diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index e3190bd8f..9a5dcbc80 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -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; diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index 7bb593843..ff546bbc7 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -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; diff --git a/shared/src/i18n/et/journey.ts b/shared/src/i18n/et/journey.ts index e064a9a1d..a37abcd60 100644 --- a/shared/src/i18n/et/journey.ts +++ b/shared/src/i18n/et/journey.ts @@ -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; diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index 13d6d2400..836402732 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -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; diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index 2e7eb4686..32ea6c26c 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -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; diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index 61dc0cca1..1e11bc013 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -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; diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index 846fe3fc6..094179202 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -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; diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index 10fcb9262..6f8c226c9 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -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; diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 7e4e51b1b..6d976a21c 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -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; diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index 62d8ed017..c54c96bfe 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -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; diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index ed0a94fc6..4f5327cfd 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -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; diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index b33e4c9fb..fa7c57579 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -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; diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 6847e3a37..76c3c5f21 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -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; diff --git a/shared/src/i18n/sk/journey.ts b/shared/src/i18n/sk/journey.ts index ff36228fa..c8af0305f 100644 --- a/shared/src/i18n/sk/journey.ts +++ b/shared/src/i18n/sk/journey.ts @@ -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; diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index f79251321..75654a466 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -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; diff --git a/shared/src/i18n/th/journey.ts b/shared/src/i18n/th/journey.ts index 41d90fe69..56416d674 100644 --- a/shared/src/i18n/th/journey.ts +++ b/shared/src/i18n/th/journey.ts @@ -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; diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index 9021abfae..b2156c3dc 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -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; diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index 98b11f3ee..c501be91a 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -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; diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index 175a9aea9..b69a8c7cf 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index 4d65b0212..314c886d0 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -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; diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index 24f81ff4a..ec41ac301 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -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;