diff --git a/client/e2e/page-snapshot-webkit.spec.ts b/client/e2e/page-snapshot-webkit.spec.ts index 61f0819a2..be6487681 100644 --- a/client/e2e/page-snapshot-webkit.spec.ts +++ b/client/e2e/page-snapshot-webkit.spec.ts @@ -6,8 +6,9 @@ import { checkPageSnapshot } from './support/pageSnapshotFlow'; // 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'] }); +// drawn too early comes out blank. page.route does not see a request the +// service worker answers, so it is blocked. +test.use({ ...devices['Desktop Safari'], serviceWorkers: 'block' }); 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 index 5a10224d5..ee11a79b7 100644 --- a/client/e2e/page-snapshot.spec.ts +++ b/client/e2e/page-snapshot.spec.ts @@ -5,6 +5,9 @@ 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. +// page.route does not see a request the service worker answers, so it is blocked. +test.use({ serviceWorkers: 'block' }); + 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 index 0a876db54..1e26121ea 100644 --- a/client/e2e/support/pageSnapshotFlow.ts +++ b/client/e2e/support/pageSnapshotFlow.ts @@ -2,22 +2,41 @@ import { expect, type Page } from '@playwright/test'; import type { SeededSnapshot } from './pageSnapshotHarness'; +/** The map tile from another server that the harness puts on the sheet. */ +const TILE = 'https://tile.openstreetmap.org/5/16/10.png?e2e=page-snapshot'; + /** * 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. + * another server on the sheet must never have been fetched for it. Every + * request to OpenStreetMap's tile servers is answered here, which keeps the + * run off the network, but only the harness's own tile counts: the sheet's + * asks for it as an image, anything else would be the rasteriser + * reaching for it. Its query string sets it apart from the tiles the app + * itself may prefetch from the same servers. The specs block the service + * worker, whose tile cache would answer before the route sees a request. */ export async function checkPageSnapshot(page: Page): Promise { + const tileFetches: string[] = []; + await page.route(/^https:\/\/(?:[a-c]\.)?tile\.openstreetmap\.org\//, (route) => { + const request = route.request(); + const kind = request.resourceType(); + if (request.url().startsWith(TILE) && kind !== 'image') tileFetches.push(kind); + return route.fulfill({ status: 204, headers: { 'access-control-allow-origin': '*' } }); + }); 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'); + const shot = await page.evaluate( + async ({ harness, tile }: { harness: string; tile: string }): Promise => { + const mod = (await import(/* @vite-ignore */ harness)) as { + snapshotSeededSpread: (tileUrl: string) => Promise; + }; + return mod.snapshotSeededSpread(tile); + }, + { harness: '/e2e/support/pageSnapshotHarness.ts', tile: TILE } + ); expect(shot, 'the spread was drawn and came out under the size limit').not.toBeNull(); if (!shot) return; @@ -30,5 +49,5 @@ export async function checkPageSnapshot(page: Page): Promise { 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); + expect(tileFetches, 'the tile from another server is left out before anything is fetched').toEqual([]); } diff --git a/client/e2e/support/pageSnapshotHarness.ts b/client/e2e/support/pageSnapshotHarness.ts index 63bc0e469..a5dd480ac 100644 --- a/client/e2e/support/pageSnapshotHarness.ts +++ b/client/e2e/support/pageSnapshotHarness.ts @@ -11,7 +11,8 @@ import { sheetSizePx, snapshotSpread } from '../../src/components/Studio/pageSna * 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. + * tile from another server sits on the sheet and must be left out; the flow + * names the tile and watches its server. */ export interface SeededSnapshot { @@ -25,12 +26,8 @@ export interface SeededSnapshot { 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', @@ -50,7 +47,7 @@ function seededSpread(): BookSpread { }; } -export async function snapshotSeededSpread(): Promise { +export async function snapshotSeededSpread(tileUrl: string): Promise { const page = bookPageSetupForPreset('square-210'); const spread = seededSpread(); const size = sheetSizePx(spread, page); @@ -64,15 +61,13 @@ export async function snapshotSeededSpread(): Promise { const root = createRoot(sheet); flushSync(() => root.render(createElement(SpreadView, { spread, page, print: true }))); const tile = document.createElement('img'); - tile.src = TILE; + tile.src = tileUrl; 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, @@ -82,7 +77,6 @@ export async function snapshotSeededSpread(): Promise { decodedWidth: bitmap.width, decodedHeight: bitmap.height, drawnPhotoIds: shot.drawnPhotoIds, - tileRequested: fetched.some((entry) => entry.name === TILE && entry.initiatorType !== 'img'), }; } finally { root.unmount(); diff --git a/client/src/components/Assistant/AssistantHeader.tsx b/client/src/components/Assistant/AssistantHeader.tsx index 00e6b3a32..d58b95b9c 100644 --- a/client/src/components/Assistant/AssistantHeader.tsx +++ b/client/src/components/Assistant/AssistantHeader.tsx @@ -3,6 +3,7 @@ import { type HTMLAttributes, type MouseEvent, useState } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from '../../i18n'; +import { assistantScopeOf } from '../../store/assistantChatKey'; import { PopoverItem } from '../Packing/PackingPopover'; import ConfirmDialog from '../shared/ConfirmDialog'; import { ContextMenu, useContextMenu } from '../shared/ContextMenu'; @@ -36,12 +37,14 @@ interface AssistantHeaderProps { * grabbed to move it. On the end side the recent chats (a new chat starts at * the top of that list), more and close, as round raised buttons. Closing * keeps the conversation, and an answer being written goes on. The model is not picked here; - * that lives in the settings. + * that lives in the settings. Notes are a trip's: a journey's chat in TREK Studio shows no notes button. */ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) { const { t, locale } = useTranslation(); const live = useAssistantLiveStep(tripId); const memory = useTripMemory(tripId); + // Trip notes belong to a trip: a journey's turns never read them (assistant-scope.ts turnOf). + const tripChat = assistantScopeOf(tripId).kind === 'trip'; const [memoryOpen, setMemoryOpen] = useState(false); const navigate = useNavigate(); const menu = useContextMenu(); @@ -123,22 +126,24 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr onPointerDown={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} > - - - + {tripChat && ( + + + + )} diff --git a/client/src/components/Assistant/useAssistantSuggestions.ts b/client/src/components/Assistant/useAssistantSuggestions.ts index b92766612..4e2151ba0 100644 --- a/client/src/components/Assistant/useAssistantSuggestions.ts +++ b/client/src/components/Assistant/useAssistantSuggestions.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { useTranslation } from '../../i18n'; import { assistantScopeOf } from '../../store/assistantChatKey'; +import { useJourneyStore } from '../../store/journeyStore'; import { useCanDo } from '../../store/permissionsStore'; import { useTripStore } from '../../store/tripStore'; import { localIsoDate } from '../../utils/localDate'; @@ -19,7 +20,8 @@ export interface ReadySuggestion { * the user may change this trip at all. For a trip the planner is not showing * (one picked from the dashboard) neither its dates nor its rights are at * hand, so it gets the general questions and none that would change it. A - * journey's chat in TREK Studio gets none: they all ask about a trip. + * journey's chat in TREK Studio gets none, since they all ask about a trip, + * and its rights are the journey's role, as the Studio itself reads it. */ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } { const { t } = useTranslation(); @@ -27,8 +29,13 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null)); const hasPlaces = useTripStore((s) => s.places.length > 0); const { label, kind } = useAssistantFocus(tripId); - const canEdit = !!trip && can('place_edit', trip); - const tripChat = assistantScopeOf(tripId).kind === 'trip'; + const scope = assistantScopeOf(tripId); + const tripChat = scope.kind === 'trip'; + // The Studio's own reading: an owner until the journey says otherwise, and only a viewer changes nothing. + const journeyRole = useJourneyStore((s) => + scope.kind === 'journey' && s.current?.id === scope.journeyId ? s.current.my_role : undefined + ); + const canEdit = tripChat ? !!trip && can('place_edit', trip) : (journeyRole ?? 'owner') !== 'viewer'; const suggestions = useMemo((): ReadySuggestion[] => { if (!tripChat) return []; diff --git a/client/src/components/Studio/StudioAssistantPanel.test.tsx b/client/src/components/Studio/StudioAssistantPanel.test.tsx index 4d084beaa..921545665 100644 --- a/client/src/components/Studio/StudioAssistantPanel.test.tsx +++ b/client/src/components/Studio/StudioAssistantPanel.test.tsx @@ -1,22 +1,25 @@ /** - * FE-STUDIO-ASTPANEL-001 to FE-STUDIO-ASTPANEL-003 + * FE-STUDIO-ASTPANEL-001 to FE-STUDIO-ASTPANEL-009 * * 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. + * leave only once the user agreed, for the connection on screen, that "Not + * now" keeps it text for the visit, and that a model reading no images is + * never asked; 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, while Escape in the header's menu only closes the menu; the + * journey's chat offers no trip notes, and its privacy banner speaks of the + * journey; the bar's switch and the column it opens. */ 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 { fireEvent, render, screen, waitFor, within } 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 StudioAssistantPanel, { StudioAssistantColumn, StudioAssistantToggle } from './StudioAssistantPanel'; import type { StudioAssistant } from './useStudioAssistant'; const JOURNEY = 12; @@ -32,13 +35,24 @@ const STATUS: AiStatusResponse = { instance: { kind: 'anthropic', model: 'claude', host: 'api.anthropic.com' }, quota: null, privacyAcked: true, + studioImages: 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() }; +function assistant(over: Partial = {}): StudioAssistant { + return { chatKey: KEY, available: true, open: true, toggle: vi.fn(), jobs: [], drawn: vi.fn(), ...over }; +} + +/** A chat whose answer is still being written, with the store's cancel spied on. */ +function running() { + const cancel = vi.fn(); + useAssistantStore.setState({ + cancel, + byTrip: { [KEY]: { ...chatOf(useAssistantStore.getState(), KEY), isRunning: true } }, + }); + return cancel; } beforeEach(() => { @@ -83,11 +97,7 @@ describe('StudioAssistantPanel', () => { }); 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 cancel = running(); const studioKeys = vi.fn(); window.addEventListener('keydown', studioKeys); const panel = assistant(); @@ -105,4 +115,94 @@ describe('StudioAssistantPanel', () => { expect(panel.toggle).not.toHaveBeenCalled(); window.removeEventListener('keydown', studioKeys); }); + + it("FE-STUDIO-ASTPANEL-004: Escape in the header's menu closes the menu, not the answer or Studio", async () => { + const cancel = running(); + const studioKeys = vi.fn(); + window.addEventListener('keydown', studioKeys); + const panel = assistant(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'More options' })); + const item = within(await screen.findByRole('menu')).getAllByRole('button')[0] as HTMLElement; + // The menu's own keys stay out of the Studio and leave the answer alone. + fireEvent.keyDown(item, { key: 'ArrowDown' }); + fireEvent.keyDown(item, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + expect(cancel).not.toHaveBeenCalled(); + expect(studioKeys).not.toHaveBeenCalled(); + expect(panel.toggle).not.toHaveBeenCalled(); + window.removeEventListener('keydown', studioKeys); + }); + + it('FE-STUDIO-ASTPANEL-005: the journey chat offers no trip notes, which only a trip turn reads', async () => { + render(); + + expect(await screen.findByText('What should the book look like?')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Recent chats' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Trip notes' })).toBeNull(); + }); + + it('FE-STUDIO-ASTPANEL-006: on the admin connection the privacy banner speaks of the journey', async () => { + useAssistantStore.setState({ status: { ...STATUS, privacyAcked: false } }); + render(); + + expect( + await screen.findByText('Your messages and the journey data the assistant reads go to Anthropic.') + ).toBeInTheDocument(); + expect(screen.queryByText(/trip data/)).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'What gets shared?' })); + expect( + screen.getByText('Not your password or your other journeys. A picture of a page goes only once you allow it.') + ).toBeInTheDocument(); + expect(screen.getByText(/what the assistant reads of this journey/)).toBeInTheDocument(); + expect(screen.queryByText('Not your password, your files or your other trips.')).toBeNull(); + }); + + it('FE-STUDIO-ASTPANEL-007: a connection whose model reads no images is never asked to show pages', async () => { + useAssistantStore.setState({ + status: { + ...STATUS, + effective: { source: 'instance', kind: 'custom', model: 'llama3', host: 'ollama.lan', paidBy: 'instance' }, + studioImages: false, + }, + }); + render(); + + expect(await screen.findByText('What should the book look like?')).toBeInTheDocument(); + expect(screen.queryByRole('note')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Show it pages' })).toBeNull(); + }); + + it('FE-STUDIO-ASTPANEL-008: the bar switch shows where the assistant runs, tells its state and toggles', () => { + const { unmount } = render(); + expect(screen.queryByRole('button', { name: 'Assistant' })).toBeNull(); + unmount(); + + const closed = assistant({ open: false }); + const second = render(); + const toggle = screen.getByRole('button', { name: 'Assistant' }); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + expect(toggle).toHaveAttribute('title', 'Design the book together with the assistant'); + expect(toggle).not.toHaveClass('is-on'); + fireEvent.click(toggle); + expect(closed.toggle).toHaveBeenCalledTimes(1); + second.unmount(); + + render(); + expect(screen.getByRole('button', { name: 'Assistant' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Assistant' })).toHaveClass('is-on'); + }); + + it('FE-STUDIO-ASTPANEL-009: the column mounts only while it is open, named after the journey or as one', async () => { + const { unmount } = render(); + expect(screen.queryByRole('complementary', { name: 'Book assistant' })).toBeNull(); + unmount(); + + render(); + expect(screen.getByRole('complementary', { name: 'Book assistant' })).toBeInTheDocument(); + expect(await screen.findByText('What should the book look like?')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Journey' })).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Studio/StudioAssistantPanel.tsx b/client/src/components/Studio/StudioAssistantPanel.tsx index ba8f75260..e11cd6bd5 100644 --- a/client/src/components/Studio/StudioAssistantPanel.tsx +++ b/client/src/components/Studio/StudioAssistantPanel.tsx @@ -1,6 +1,6 @@ import '../../styles/studio-assistant.css'; -import { ImageIcon } from 'lucide-react'; +import { ImageIcon, MessageSquare } from 'lucide-react'; import { type HTMLAttributes, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; @@ -23,7 +23,8 @@ import type { StudioAssistant, StudioRenderJob } from './useStudioAssistant'; * * 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. + * Escape stops a running answer first. A key from the header's menu, which + * sits in a portal, is not the chat's to act on. */ /** The column is not a window: its head moves nothing. */ @@ -32,17 +33,20 @@ 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. + * notice for this visit; the next one asks again. A model that reads no + * images (a custom server, OpenRouter, an OpenAI text-only model) is never + * asked: its pages always go as text. */ function StudioImagesNotice() { const { t } = useTranslation(); const toast = useToast(); const effective = useAssistantStore((s) => s.status?.effective ?? null); + const reads = useAssistantStore((s) => s.status?.studioImages === true); 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; + if (!effective || !reads || acked || later) return null; const provider = providerLabel(effective.kind, effective.host); const allow = async () => { @@ -146,9 +150,12 @@ export default function StudioAssistantPanel({ assistant, title }: { assistant: // 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. + // Keys from a portal (the header's menu) bubble here through React too; + // they stop here as well, but they are not the chat's to act on. const onKeyDown = (e: KeyboardEvent) => { + const inside = e.currentTarget.contains(e.target as Node); e.stopPropagation(); - if (e.key === 'Escape' && isRunning && !e.defaultPrevented) stop(); + if (inside && e.key === 'Escape' && isRunning && !e.defaultPrevented) stop(); }; return ( @@ -171,3 +178,28 @@ export default function StudioAssistantPanel({ assistant, title }: { assistant: ); } + +/** The Studio bar's switch for the column; only where the assistant can run for this user. */ +export function StudioAssistantToggle({ assistant }: { assistant: StudioAssistant }) { + const { t } = useTranslation(); + if (!assistant.available) return null; + return ( + + ); +} + +/** The chat column at the end of the Studio's body, while it is open, named after the journey. */ +export function StudioAssistantColumn({ assistant, title }: { assistant: StudioAssistant; title?: string }) { + const { t } = useTranslation(); + if (!assistant.open) return null; + return ; +} diff --git a/client/src/components/Studio/StudioSidebar.tsx b/client/src/components/Studio/StudioSidebar.tsx index ea6b215f9..2390d4df6 100644 --- a/client/src/components/Studio/StudioSidebar.tsx +++ b/client/src/components/Studio/StudioSidebar.tsx @@ -168,6 +168,8 @@ function PagesPanel({ }: { page: BookPageSetup; pxPerMm: number; bookView: boolean; t: (k: string) => string }) { const doc = useStudioStore(s => s.doc) const active = useStudioStore(s => s.activeSpread) + // The page a chat card points at, outlined here (journey-studio.md 4.2). + const hinted = useStudioStore(s => s.hintSpread) const setActive = useStudioStore(s => s.setActiveSpread) const addSpread = useStudioStore(s => s.addSpread) const insertSpread = useStudioStore(s => s.insertSpread) @@ -194,7 +196,7 @@ function PagesPanel({ return (
+ ); +} + +afterEach(() => { + useStudioStore.setState(STUDIO, true); +}); + +describe('useStudioPageHint', () => { + it('FE-STUDIO-HINT-001: pointing at a card page outlines that page in the rail, leaving takes it off', () => { + act(() => useStudioStore.getState().load(book())); + renderRail(); + render(); + const page = screen.getByRole('button', { name: 'card page sp-3' }); + expect(outlined()).toEqual([false, false, false, false, false]); + + fireEvent.pointerEnter(page); + expect(useStudioStore.getState().hintSpread).toBe('sp-3'); + expect(outlined()).toEqual([false, false, true, false, false]); + fireEvent.pointerLeave(page); + expect(outlined()).toEqual([false, false, false, false, false]); + + // The keyboard points the same way. + fireEvent.focus(page); + expect(useStudioStore.getState().hintSpread).toBe('sp-3'); + fireEvent.blur(page); + expect(useStudioStore.getState().hintSpread).toBeNull(); + }); + + it('FE-STUDIO-HINT-002: a page the book lacks outlines nothing; a card gone or a book opened clears it', () => { + act(() => useStudioStore.getState().load(book())); + render(); + fireEvent.pointerEnter(screen.getByRole('button', { name: 'card page sp-new' })); + expect(useStudioStore.getState().hintSpread).toBeNull(); + + const card = render(); + fireEvent.pointerEnter(screen.getByRole('button', { name: 'card page sp-4' })); + expect(useStudioStore.getState().hintSpread).toBe('sp-4'); + card.unmount(); + expect(useStudioStore.getState().hintSpread).toBeNull(); + + // A card going away that points at no page leaves another card's outline alone. + const other = render(); + act(() => useStudioStore.getState().setHintSpread('sp-5')); + other.unmount(); + expect(useStudioStore.getState().hintSpread).toBe('sp-5'); + act(() => useStudioStore.getState().load(book())); + expect(useStudioStore.getState().hintSpread).toBeNull(); + }); + + it("FE-STUDIO-HINT-003: a book change's thumbnail points at its page in the Studio's rail", () => { + act(() => useStudioStore.getState().load(book())); + renderRail(); + const card = { + kind: 'bookPage', + journeyId: 3, + change: 'changed', + pages: [{ spreadId: 'sp-3', index: 2, role: 'inner', number: 1, photos: 0 }], + ops: [], + cover: null, + } as unknown as BookPageCard; + const item = { state: 'applied' } as ChangeItem; + render( + + + + ); + + const thumb = screen.getByRole('button', { name: 'Show this page' }); + fireEvent.pointerEnter(thumb); + expect(outlined()).toEqual([false, false, true, false, false]); + fireEvent.pointerLeave(thumb); + expect(outlined()).toEqual([false, false, false, false, false]); + }); +}); diff --git a/client/src/components/Studio/useStudioPageHint.ts b/client/src/components/Studio/useStudioPageHint.ts new file mode 100644 index 000000000..a9cc644b3 --- /dev/null +++ b/client/src/components/Studio/useStudioPageHint.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect } from 'react'; + +import { useStudioStore } from '../../store/studioStore'; + +/** What a page drawn in a chat card spreads on its button so the Studio's page rail outlines that page. */ +interface StudioPageHint { + onPointerEnter: () => void; + onPointerLeave: () => void; + onFocus: () => void; + onBlur: () => void; +} + +/** + * Pointing at a page drawn in a chat card, with the pointer or the keyboard's + * focus, outlines the same page in the Studio's page rail (journey-studio.md + * 4.2); leaving it, or the card going away while it points, takes the outline + * off again. A page the open book does not hold (a new page of a design, + * another book) outlines nothing. + */ +export function useStudioPageHint(spreadId: string): StudioPageHint { + const setHint = useStudioStore((s) => s.setHintSpread); + + const point = useCallback(() => { + const held = useStudioStore.getState().doc?.spreads.some((spread) => spread.id === spreadId) ?? false; + setHint(held ? spreadId : null); + }, [spreadId, setHint]); + + const leave = useCallback(() => { + if (useStudioStore.getState().hintSpread === spreadId) setHint(null); + }, [spreadId, setHint]); + + // A card that re-renders or leaves the thread while pointed at takes its outline with it. + useEffect(() => leave, [leave]); + + return { onPointerEnter: point, onPointerLeave: leave, onFocus: point, onBlur: leave }; +} diff --git a/client/src/components/shared/ContextMenu.extra.test.tsx b/client/src/components/shared/ContextMenu.extra.test.tsx index 37f347045..3f38afc6b 100644 --- a/client/src/components/shared/ContextMenu.extra.test.tsx +++ b/client/src/components/shared/ContextMenu.extra.test.tsx @@ -1,4 +1,4 @@ -// FE-W4CTX-001 to FE-W4CTX-010 +// FE-W4CTX-001 to FE-W4CTX-013 import { describe, it, expect, vi, afterEach } from 'vitest' import { Trash2 } from 'lucide-react' import { renderHook, act } from '@testing-library/react' @@ -124,6 +124,26 @@ describe('ContextMenu', () => { expect(onClose).toHaveBeenCalledTimes(3) }) + it('FE-W4CTX-013: Escape on an item closes the menu and goes no further, so nothing under it acts on it', () => { + const onClose = vi.fn() + const parentKeys = vi.fn() + const documentKeys = vi.fn() + document.addEventListener('keydown', documentKeys) + render( +
+ +
, + ) + fireEvent.keyDown(screen.getByRole('button', { name: 'Open' }), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + expect(parentKeys).not.toHaveBeenCalled() + expect(documentKeys).not.toHaveBeenCalled() + // Any other key still reaches the page. + fireEvent.keyDown(screen.getByRole('button', { name: 'Open' }), { key: 'a' }) + expect(parentKeys).toHaveBeenCalledOnce() + document.removeEventListener('keydown', documentKeys) + }) + it('FE-W4CTX-012: focus starts on the first usable item and the arrow keys move it, wrapping', () => { render( {}} />) expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus() diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index 8e354f8b5..f9bf5b637 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -56,17 +56,23 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) { if (!menu) return const handler = () => onClose() // A fixed menu stays where it opened, so it closes when the page under it - // moves or the window changes size, and Escape closes it like any menu. - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + // moves or the window changes size. Escape closes it in the capture phase + // and goes no further, so a dialog, the assistant column or Studio under + // it does not act on the same key. + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + e.stopPropagation() + onClose() + } document.addEventListener('click', handler) document.addEventListener('contextmenu', handler) - document.addEventListener('keydown', onKey) + document.addEventListener('keydown', onKey, true) window.addEventListener('scroll', handler, true) window.addEventListener('resize', handler) return () => { document.removeEventListener('click', handler) document.removeEventListener('contextmenu', handler) - document.removeEventListener('keydown', onKey) + document.removeEventListener('keydown', onKey, true) window.removeEventListener('scroll', handler, true) window.removeEventListener('resize', handler) } diff --git a/client/src/store/studioStore.ts b/client/src/store/studioStore.ts index cb4dc1b4c..65d6bbdeb 100644 --- a/client/src/store/studioStore.ts +++ b/client/src/store/studioStore.ts @@ -32,6 +32,11 @@ interface StudioState { gestureBase: BookDocument | null /** Elements copied for pasting onto any page of the book (#2316). Kept across page switches. */ clipboard: StudioClipboard | null + /** + * The page a chat card points at, outlined in the page rail (journey-studio.md + * 4.2). Not part of the book or its history; opening a book clears it. + */ + hintSpread: string | null load: (doc: BookDocument) => void /** @@ -50,6 +55,7 @@ interface StudioState { */ adoptRemote: (doc: BookDocument, opts?: { undoable?: boolean }) => void setActiveSpread: (i: number) => void + setHintSpread: (spreadId: string | null) => void select: (ids: string[]) => void toggleSelect: (id: string, additive: boolean) => void @@ -128,8 +134,9 @@ export const useStudioStore = create((set, get) => ({ future: [], gestureBase: null, clipboard: null, + hintSpread: null, - load: doc => set({ doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null }), + load: doc => set({ doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null, hintSpread: null }), adoptRemote: (doc, opts) => set(s => { if (!s.doc) return { doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null } if (doc === s.doc) return {} @@ -154,6 +161,7 @@ export const useStudioStore = create((set, get) => ({ } }), setActiveSpread: i => set({ activeSpread: i, selection: [] }), + setHintSpread: spreadId => set({ hintSpread: spreadId }), select: ids => set({ selection: ids }), toggleSelect: (id, additive) => set(s => { if (!additive) return { selection: [id] } diff --git a/client/src/styles/studio-assistant.css b/client/src/styles/studio-assistant.css index c4e30c71d..091cb000a 100644 --- a/client/src/styles/studio-assistant.css +++ b/client/src/styles/studio-assistant.css @@ -31,3 +31,10 @@ .trek-dash .st-assistant-notice { margin: 8px 12px 0; } + +/* The page a chat card points at, outlined in the page rail (journey-studio.md 4.2). + Inset, so the rail's scroll box does not clip it, and apart from the border the + page on screen is marked with. */ +.trek-dash .st-thumb.is-hinted { + box-shadow: inset 0 0 0 2px color-mix(in oklch, var(--accent) 55%, transparent); +} diff --git a/server/src/nest/ai-connections/ai-connections.service.ts b/server/src/nest/ai-connections/ai-connections.service.ts index 7c45c7571..5f030a3cd 100644 --- a/server/src/nest/ai-connections/ai-connections.service.ts +++ b/server/src/nest/ai-connections/ai-connections.service.ts @@ -23,7 +23,7 @@ import { maskHint, openSecrets, sealSecrets } from './ai-secrets'; import { AiSettingsService } from './ai-settings.service'; import { AiUsageService } from './ai-usage.service'; import { ChatgptAuthService } from './chatgpt-auth.service'; -import { ModelCatalogService, pickDefaultModel, type CatalogTarget } from './model-catalog.service'; +import { ModelCatalogService, modelReadsImages, pickDefaultModel, type CatalogTarget } from './model-catalog.service'; import { AiProviderError } from './providers/provider-errors'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable } from '@nestjs/common'; @@ -89,6 +89,9 @@ export class AiConnectionsService implements SharedAiConnectionPort { private readonly audit: AuditService, ) {} + /** `kind:model` of the models a provider refused an image for, while this process runs. */ + private readonly textOnly = new Set(); + /** Whether the assistant exists here at all: the addon is on and the install is not centrally administered. */ async enabled(): Promise { return !readEnv().managed.enabled && (await this.addons.isAddonEnabled(ADDON_IDS.AI_ASSISTANT)); @@ -129,6 +132,7 @@ export class AiConnectionsService implements SharedAiConnectionPort { ? { used: await this.usage.instanceTurnsToday(userId), limit: settings.dailyTurns } : null, privacyAcked: acked, + studioImages: !!effective && this.readsImages({ kind: kindOf(effective), model: effective.model }), studioImagesAcked: !!effective && (await this.studioImagesAckedFor(userId, `${kindOf(effective)}@${host}`)), retention: settings.retention, openrouterOAuth: openrouterOAuthAvailable(), @@ -247,6 +251,21 @@ export class AiConnectionsService implements SharedAiConnectionPort { return (await this.settings.getDecryptedUserSetting(userId, AI_STUDIO_IMAGES_ACK_SETTING)) === target; } + /** + * Whether a model is shown the Studio's pages as images: its name says it + * reads them, and no provider refused an image for it while this process + * runs. Status asks this too, so the Studio offers images only where a turn + * would send them. + */ + readsImages({ kind, model }: Pick): boolean { + return modelReadsImages(kind, model) && !this.textOnly.has(`${kind}:${model}`); + } + + /** A provider refused an image for this model: it reads pages in words until the process restarts. */ + refuseImages({ kind, model }: Pick): void { + this.textOnly.add(`${kind}:${model}`); + } + // ── The instance's connection (admin) ────────────────────────────────────── async readSettings(): Promise { diff --git a/server/src/nest/assistant/assistant-vision.ts b/server/src/nest/assistant/assistant-vision.ts index ef1a38384..0cc9329d5 100644 --- a/server/src/nest/assistant/assistant-vision.ts +++ b/server/src/nest/assistant/assistant-vision.ts @@ -1,7 +1,6 @@ import type { AiChatMessage, AiImage } from '../ai-connections/ai-chat-client.interface'; import { AiConnectionsService } from '../ai-connections/ai-connections.service'; import type { ResolvedAiConnection } from '../ai-connections/ai-connections.types'; -import { modelReadsImages } from '../ai-connections/model-catalog.service'; import { logDebug } from '../audit/audit-log.logger'; import { DomainError } from '../common/domain-error'; import { withRequestContext } from '../database/request-context'; @@ -23,6 +22,7 @@ import { type BookDocument, type BookElement, type BookPageOp, + type BookSpread, } from '@trek/shared'; import { randomUUID } from 'node:crypto'; @@ -142,7 +142,7 @@ function reason(err: unknown): string { return err instanceof Error ? err.message : String(err); } -/** Builds each Studio turn's vision, and remembers the models a provider refused an image for. */ +/** Builds each Studio turn's vision; the connections remember the models a provider refused an image for. */ @Injectable() export class AssistantVision { constructor( @@ -153,9 +153,6 @@ export class AssistantVision { private readonly orm: MikroORM, ) {} - /** `kind:model` of the models a provider refused an image for, while this process runs. */ - private readonly textOnly = new Set(); - /** A turn's tools and its vision: a Studio turn's with the page preview beside them, a trip turn's as they are. */ async turnTools( scope: AssistantScope, @@ -168,18 +165,16 @@ export class AssistantVision { } async forTurn(input: VisionTurnInput): Promise { - const model = `${input.connection.kind}:${input.connection.model}`; let images = input.renders && - modelReadsImages(input.connection.kind, input.connection.model) && - !this.textOnly.has(model) && + this.connections.readsImages(input.connection) && (await withRequestContext(this.orm, () => this.connections.studioImagesAcked(input.userId, input.connection))); const vision: TurnVision = { sendsImages: () => images, look: (spreadId, ops, designId) => this.look(input, images, spreadId, { ops, designId }), refused: () => { images = false; - this.textOnly.add(model); + this.connections.refuseImages(input.connection); }, afterApply: (history) => (images ? this.afterApply(vision, history) : Promise.resolve([...history])), }; @@ -246,8 +241,10 @@ export class AssistantVision { const photos = (base.ctx.photos ?? []).map((p) => (hidden.includes(p.photoId) ? { ...p, hidden: true } : p)); const described = describeSpread(doc, spreadId, photos); if (!described) return { ok: false, value: `This book has no page ${spreadId}: get_book lists its pages.` }; + // A proposal goes as the page applied here, with the journey's photos, words and figures the Studio lacks. + const spread = ops ? doc.spreads.find((s) => s.id === spreadId) : undefined; const image = draws - ? await this.render(input, { spreadId, bookVersion: base.version, ops, designId, hidden }) + ? await this.render(input, { spreadId, bookVersion: base.version, spread, designId, hidden }) : null; const value = { spreadId, @@ -265,18 +262,18 @@ export class AssistantVision { /** Asks the Studio tab for the page and waits for it; null when none came in time or the turn stopped. */ private async render( input: VisionTurnInput, - page: { spreadId: string; bookVersion: number; ops?: BookPageOp[]; designId?: string; hidden: number[] }, + page: { spreadId: string; bookVersion: number; spread?: BookSpread; designId?: string; hidden: number[] }, ): Promise { const requestId = randomUUID(); const { userId, journeyId, threadId, signal } = input; - const { spreadId, bookVersion, ops, designId, hidden } = page; + const { spreadId, bookVersion, spread, designId, hidden } = page; // Waiting before asking: an upload that comes back at once finds its turn. const waiting = this.registry.wait( { userId, journeyId, threadId, requestId, spreadId, bookVersion }, signal, ASSISTANT_PAGE_IMAGE.timeoutMs, ); - // A draft's page: the Studio draws it from the draft (GET .../book-designs/:designId). + // A draft's page: the Studio draws it from the draft (GET .../book-designs/:designId); a proposal's as sent. const asked = { requestId, threadId, @@ -285,7 +282,7 @@ export class AssistantVision { hiddenPhotoIds: hidden, ...(designId ? { designId } : {}), }; - input.w.renderRequest(ops ? { ...asked, ops } : asked); + input.w.renderRequest(spread ? { ...asked, spread } : asked); const image = await waiting; // Size and dimensions only: the page shows the user's photos. if (image) logDebug(`[assistant] page image ${image.width}x${image.height}, ${image.data.length} bytes`); diff --git a/server/tests/unit/nest/ai-connections/ai-connections.service.test.ts b/server/tests/unit/nest/ai-connections/ai-connections.service.test.ts index d977ac236..5cacccd1c 100644 --- a/server/tests/unit/nest/ai-connections/ai-connections.service.test.ts +++ b/server/tests/unit/nest/ai-connections/ai-connections.service.test.ts @@ -288,6 +288,34 @@ describe('the instance connection', () => { expect((await service().status(id)).studioImagesAcked).toBe(false); }); + it('AICS-017: status says whether the model the user runs on reads images, so Studio asks only then', async () => { + const admin = await user(); + const id = await user(); + await service().saveInstance(admin, { kind: 'anthropic', apiKey: 'sk-ant-instance' }); + expect((await service().status(id)).studioImages).toBe(true); + // A custom server cannot be told to read images from its model's name: its pages go as text. + await service().saveInstance(admin, { kind: 'custom', baseUrl: 'http://ollama.lan:11434/v1', model: 'qwen3:14b' }); + expect((await service().status(id)).studioImages).toBe(false); + await service().removeInstance(admin); + expect((await service().status(id)).studioImages).toBe(false); + }); + + it('AICS-018: a model a provider refused an image for reads pages in words from then on, and status says so', async () => { + const admin = await user(); + const id = await user(); + // The service, and what it remembers, lives for the whole file: no later test asks whether Haiku reads images. + await service().saveInstance(admin, { kind: 'anthropic', apiKey: 'sk-ant-instance', model: 'claude-haiku' }); + const haiku = { kind: 'anthropic' as const, model: 'claude-haiku' }; + expect(service().readsImages(haiku)).toBe(true); + expect((await service().status(id)).studioImages).toBe(true); + + service().refuseImages(haiku); + expect(service().readsImages(haiku)).toBe(false); + expect((await service().status(id)).studioImages).toBe(false); + // Only that model: another one of the same provider still gets its pages as pictures. + expect(service().readsImages({ ...haiku, model: 'claude-sonnet' })).toBe(true); + }); + it('AICS-014: settings are read with their defaults and written back', async () => { const admin = await user(); expect(await service().readSettings()).toEqual({ diff --git a/server/tests/unit/nest/assistant/assistant-vision.test.ts b/server/tests/unit/nest/assistant/assistant-vision.test.ts index 3ac166b01..72fc6a419 100644 --- a/server/tests/unit/nest/assistant/assistant-vision.test.ts +++ b/server/tests/unit/nest/assistant/assistant-vision.test.ts @@ -2,14 +2,17 @@ * AssistantVision (ASTVSN-*): a Studio turn looking at a page. Images go only * when the client draws pages, the model reads images and the user let the * connection receive them; only the user's own photos are drawn; no image in - * time is the description alone; a refused image makes the model text-only - * for the process; a decision's turn starts with views of the pages the - * applied book writes left. The book, the photos and the acknowledgement are - * fakes; the registry is the real in-memory one, the request context a real - * ORM over a snapshot test db. + * time is the description alone; a refused image makes the model text-only, + * which the connections remember for the process; a decision's turn starts + * with views of the pages the applied book writes left. The book, the photos + * and the connections are fakes; the registry is the real in-memory one, the + * request context a real ORM over a snapshot test db. A proposal goes to the + * Studio as the page the server applied, with the book context the Studio + * does not hold. */ import type { AiChatMessage } from '../../../../src/nest/ai-connections/ai-chat-client.interface'; import type { AiConnectionsService } from '../../../../src/nest/ai-connections/ai-connections.service'; +import { modelReadsImages } from '../../../../src/nest/ai-connections/model-catalog.service'; import { InMemoryAssistantRenderRegistry } from '../../../../src/nest/assistant/assistant-render.registry'; import type { AssistantToolSession } from '../../../../src/nest/assistant/assistant-tools.service'; import { AssistantVision, type VisionTurnInput } from '../../../../src/nest/assistant/assistant-vision'; @@ -63,7 +66,12 @@ interface Fakes { function setup(fakes: Fakes = {}) { const registry = new InMemoryAssistantRenderRegistry(); - const connections = { studioImagesAcked: vi.fn(async () => fakes.acked ?? true) }; + const connections = { + studioImagesAcked: vi.fn(async () => fakes.acked ?? true), + // What the model's name says; the models refused an image are AiConnectionsService's own (AICS-018). + readsImages: vi.fn(({ kind, model }: VisionTurnInput['connection']) => modelReadsImages(kind, model)), + refuseImages: vi.fn(), + }; const fixture = book(); const books = { trialBase: vi.fn(async () => ({ @@ -142,11 +150,16 @@ describe('AssistantVision', () => { expect(run.value).not.toHaveProperty('image'); }); - it('ASTVSN-004: a proposal is drawn on a copy with its ops; no image in time leaves the description', async () => { + it('ASTVSN-004: a proposal goes as the page applied on a copy; no image in time leaves the description', async () => { const t = setup({ upload: false }); const ops = [{ op: 'background' as const, color: '#112233' }]; const run = await (await t.vision.forTurn(t.input)).look(t.inner, ops); - expect(t.asked[0]).toMatchObject({ spreadId: t.inner, bookVersion: 4, ops }); + expect(t.asked[0]).toMatchObject({ + spreadId: t.inner, + bookVersion: 4, + spread: { id: t.inner, background: '#112233' }, + }); + expect(t.asked[0]).not.toHaveProperty('ops'); expect(run.ok).toBe(true); expect(run.images).toBeUndefined(); expect(run.value).toMatchObject({ proposed: true, image: expect.stringContaining('did not send') }); @@ -167,15 +180,19 @@ describe('AssistantVision', () => { expect(t.asked).toEqual([]); }); - it('ASTVSN-006: a refused image makes the model text-only, for this turn and the next', async () => { + it('ASTVSN-006: a refused image makes the model text-only, for this turn and, told to the connections, the next', async () => { const t = setup(); const vision = await t.vision.forTurn(t.input); + expect(t.connections.readsImages).toHaveBeenCalledWith(t.input.connection); vision.refused(); expect(vision.sendsImages()).toBe(false); expect((await vision.look(t.inner)).images).toBeUndefined(); + expect(t.connections.refuseImages).toHaveBeenCalledWith(t.input.connection); + // The connections now say the model reads no images: the next turn asks for none and is not asked to say yes. + t.connections.readsImages.mockReturnValueOnce(false); + t.connections.studioImagesAcked.mockClear(); expect((await t.vision.forTurn(t.input)).sendsImages()).toBe(false); - const other = { ...t.input.connection, model: 'claude-opus-5' }; - expect((await t.vision.forTurn({ ...t.input, connection: other })).sendsImages()).toBe(true); + expect(t.connections.studioImagesAcked).not.toHaveBeenCalled(); }); it('ASTVSN-007: a decision turn starts with a view of each page the applied book writes left', async () => { @@ -242,6 +259,24 @@ describe('AssistantVision', () => { expect(t.asked).toEqual([]); }); + it('ASTVSN-012: a proposal is drawn as the server applied it, with journey words the Studio lacks', async () => { + const t = setup(); + // The journal's caption of photo 1: only the server's book context holds it. + const photos = [{ photoId: 1, caption: 'Alfama at dusk' }, { photoId: 2 }]; + t.books.trialBase.mockResolvedValueOnce({ doc: book().doc, version: 4, ctx: { photos } }); + const vision = await t.vision.forTurn(t.input); + + const run = await vision.look(t.inner, [{ op: 'caption', photoElementId: 'p-own' }]); + + expect(run.ok).toBe(true); + const drawn = t.asked[0]?.spread; + expect(drawn?.id).toBe(t.inner); + expect(drawn?.elements.some((el) => el.kind === 'text' && el.text === 'Alfama at dusk')).toBe(true); + // The page as it stands is drawn from the book the Studio reads, never sent along. + await vision.look(t.inner); + expect(t.asked[1]).not.toHaveProperty('spread'); + }); + it('ASTVSN-011: a page of a design draft is read from the draft and the Studio is told to draw it from there', async () => { const t = setup(); const vision = await t.vision.forTurn(t.input); diff --git a/shared/src/ai/ai-connections.schema.ts b/shared/src/ai/ai-connections.schema.ts index f7e01a61b..14478a59e 100644 --- a/shared/src/ai/ai-connections.schema.ts +++ b/shared/src/ai/ai-connections.schema.ts @@ -92,6 +92,9 @@ export const aiStatusResponseSchema = z.object({ privacyAcked: z.boolean(), // Whether the user also let kind@host receive images of their Studio pages, with their own photos on them. studioImagesAcked: z.boolean().optional(), + // Whether the model the effective connection runs on reads images (its name says so and no provider refused one for + // it since the server started), so a Studio page can go as a picture. + studioImages: z.boolean().optional(), retention: aiRetentionSchema, // OpenRouter's OAuth needs an https callback; without one the client offers the key field instead. openrouterOAuth: z.boolean(), diff --git a/shared/src/assistant/assistant-vision.schema.spec.ts b/shared/src/assistant/assistant-vision.schema.spec.ts index e592b52c3..a655aeb73 100644 --- a/shared/src/assistant/assistant-vision.schema.spec.ts +++ b/shared/src/assistant/assistant-vision.schema.spec.ts @@ -21,11 +21,13 @@ const RENDER = { const VIEW = { spreadId: 'sp-1', bookVersion: 7, width: 1280, height: 640, bytes: 182_000, description: 'Page 2' }; describe('the Studio page image', () => { - it('ASTVIS-001: a render request names the page, the version and the photos left out; ops are a proposal', () => { + it('ASTVIS-001: a render request names the page, the version and the photos left out; a proposal its page', () => { expect(assistantPageRenderSchema.safeParse(RENDER).success).toBe(true); - const ops = [{ op: 'background', color: '#ffffff' }]; - expect(assistantPageRenderSchema.safeParse({ ...RENDER, ops }).success).toBe(true); - expect(assistantPageRenderSchema.safeParse({ ...RENDER, ops: [] }).success).toBe(false); + // A proposal comes as the page the server applied it to, for the Studio to draw as it is. + const spread = { id: 'sp-1', role: 'inner', background: '#ffffff', elements: [], parked: [], entryId: null }; + expect(assistantPageRenderSchema.parse({ ...RENDER, spread }).spread).toEqual(spread); + const notAPage = { ...spread, background: 'white' }; + expect(assistantPageRenderSchema.safeParse({ ...RENDER, spread: notAPage }).success).toBe(false); expect(assistantPageRenderSchema.safeParse({ ...RENDER, requestId: 'r1' }).success).toBe(false); expect(assistantPageRenderSchema.safeParse({ ...RENDER, spreadId: '../x' }).success).toBe(false); }); diff --git a/shared/src/assistant/assistant-vision.schema.ts b/shared/src/assistant/assistant-vision.schema.ts index e2554431b..370c50b6f 100644 --- a/shared/src/assistant/assistant-vision.schema.ts +++ b/shared/src/assistant/assistant-vision.schema.ts @@ -1,5 +1,6 @@ import { bookSpreadIdSchema } from '../book/book-op-ids'; import { bookDesignIdSchema, editBookPageArgs } from '../book/book-ops.schema'; +import { bookSpreadSchema } from '../book/book.schema'; import { idSchema } from '../common/primitives.schema'; import { z } from 'zod'; @@ -35,17 +36,23 @@ export const ASSISTANT_PAGE_IMAGE = { /** * A page the server asks the Studio to draw, streamed while the turn waits. - * `ops` are a proposal to draw on an unsaved copy (a preview); without them - * the page is drawn as the book stands at `bookVersion`. The photos in - * `hiddenPhotoIds` are drawn as empty frames, and a map stop showing one as - * a plain dot: only the asking user's own photos leave the browser. + * `spread` is a proposal's page as the server applied it (a preview): the + * Studio draws that one in place of the stored page; without it the page is + * drawn as the book stands at `bookVersion`. The photos in `hiddenPhotoIds` + * are drawn as empty frames, and a map stop showing one as a plain dot: only + * the asking user's own photos leave the browser. */ export const assistantPageRenderSchema = z.object({ requestId: z.uuid(), threadId: idSchema, spreadId: bookSpreadIdSchema, bookVersion: z.number().int().nonnegative(), - ops: editBookPageArgs.ops.optional(), + /** + * A proposal's page as the server applied it, with the journey's photos, + * words and figures: the Studio draws this one in place of the stored page, + * nothing saved. + */ + spread: bookSpreadSchema.optional(), /** A page of a design draft (design_book) rather than of the book: the Studio draws it from the draft. */ designId: bookDesignIdSchema.optional(), hiddenPhotoIds: z.array(idSchema).max(ASSISTANT_PAGE_IMAGE.maxPagePhotos), diff --git a/shared/src/i18n/ar/assistant.ts b/shared/src/i18n/ar/assistant.ts index bef9ca4a1..0e5167f23 100644 --- a/shared/src/i18n/ar/assistant.ts +++ b/shared/src/i18n/ar/assistant.ts @@ -75,6 +75,9 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': 'رسائلك وتفاصيل الرحلة التي يقرؤها المساعد، مثل الأماكن والأيام والحجوزات.', 'assistant.privacy.notShared': 'لا كلمة مرورك ولا ملفاتك ولا رحلاتك الأخرى.', 'assistant.privacy.provider': 'يعالج {provider} هذه البيانات وفق شروط الخصوصية الخاصة به.', + 'assistant.privacy.bannerBook': 'تُرسل رسائلك وبيانات يوميات الرحلة التي يقرؤها المساعد إلى {provider}.', + 'assistant.privacy.sharedBook': 'رسائلك وما يقرؤه المساعد من يوميات الرحلة هذه، مثل مدخلاتها وأماكنها وصفحات كتابها.', + 'assistant.privacy.notSharedBook': 'لا كلمة مرورك ولا يومياتك الأخرى. لا تُرسل صورة أي صفحة إلا بعد أن تسمح بذلك.', 'assistant.empty.title': 'ماذا نخطط؟', 'assistant.empty.readOnly': 'يمكنك عرض هذه الرحلة فقط، لذا أستطيع الإجابة عن الأسئلة لكن لا أغيّر شيئًا.', 'assistant.suggest.planFirstDays': 'خطط لي الأيام الأولى', diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 1828b4508..bd4e94e4b 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -976,6 +976,7 @@ const journey: TranslationStrings = { 'journey.studio.assistant.toggleHint': 'صمّم الكتاب مع المساعد', 'journey.studio.assistant.panel': 'مساعد الكتاب', 'journey.studio.assistant.welcome': 'كيف تريد أن يبدو الكتاب؟', + 'journey.studio.assistant.readOnly': 'يمكنك عرض هذا الكتاب فقط، لذا أستطيع الإجابة عن الأسئلة لكن لا أغيّر شيئًا.', 'journey.studio.assistant.notice': 'يمكن للمساعد رؤية الصفحة التي تعمل عليها. تُرسل هذه الصفحة وحدها إلى {provider} كصورة لا تظهر فيها إلا الصور التي أضفتها بنفسك. لا يمكن التعرّف على من يظهر في صورك.', 'journey.studio.assistant.noticeAllow': 'اعرض الصفحات', diff --git a/shared/src/i18n/az/assistant.ts b/shared/src/i18n/az/assistant.ts index d5c5668f0..9ae7b4b77 100644 --- a/shared/src/i18n/az/assistant.ts +++ b/shared/src/i18n/az/assistant.ts @@ -74,6 +74,12 @@ const assistant: TranslationStrings = { 'Mesajlarınız və köməkçinin oxuduğu səyahət təfərrüatları, məsələn, yerlər, günlər və bronlar.', 'assistant.privacy.notShared': 'Parolunuz, fayllarınız və digər səyahətləriniz yox.', 'assistant.privacy.provider': '{provider} bunları öz məxfilik şərtlərinə uyğun emal edir.', + 'assistant.privacy.bannerBook': + 'Mesajlarınız və köməkçinin oxuduğu səyahət gündəliyi məlumatları {provider} xidmətinə göndərilir.', + 'assistant.privacy.sharedBook': + 'Mesajlarınız və köməkçinin bu səyahət gündəliyindən oxuduqları, məsələn, qeydlər, yerlər və kitab səhifələri.', + 'assistant.privacy.notSharedBook': + 'Parolunuz və digər səyahət gündəlikləriniz yox. Səhifənin şəkli yalnız siz icazə verdikdən sonra göndərilir.', 'assistant.empty.title': 'Nəyi planlaşdırırıq?', 'assistant.empty.readOnly': 'Bu səyahətə yalnız baxa bilərsiniz, ona görə suallara cavab verə bilərəm, amma heç nəyi dəyişə bilmərəm.', diff --git a/shared/src/i18n/az/journey.ts b/shared/src/i18n/az/journey.ts index 96ccfef4a..c60ec9b98 100644 --- a/shared/src/i18n/az/journey.ts +++ b/shared/src/i18n/az/journey.ts @@ -978,6 +978,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Bu kitaba yalnız baxa bilərsiniz, ona görə suallara cavab verə bilərəm, amma heç nəyi dəyişə bilmərəm.', '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', diff --git a/shared/src/i18n/br/assistant.ts b/shared/src/i18n/br/assistant.ts index 6af521b95..4b926d6c6 100644 --- a/shared/src/i18n/br/assistant.ts +++ b/shared/src/i18n/br/assistant.ts @@ -74,6 +74,11 @@ const assistant: TranslationStrings = { 'Suas mensagens e os detalhes da viagem que o assistente lê, como lugares, dias e reservas.', 'assistant.privacy.notShared': 'Não sua senha, seus arquivos nem suas outras viagens.', 'assistant.privacy.provider': '{provider} trata esses dados de acordo com seus próprios termos de privacidade.', + 'assistant.privacy.bannerBook': 'Suas mensagens e os dados da jornada que o assistente lê vão para {provider}.', + 'assistant.privacy.sharedBook': + 'Suas mensagens e o que o assistente lê desta jornada, como as entradas, os lugares e as páginas do livro.', + 'assistant.privacy.notSharedBook': + 'Não sua senha nem suas outras jornadas. A imagem de uma página só vai depois que você permitir.', 'assistant.empty.title': 'O que vamos planejar?', 'assistant.empty.readOnly': 'Você só pode visualizar esta viagem, então posso responder perguntas, mas não alterar nada.', diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index 819ba314c..87f9d346f 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -908,6 +908,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Você só pode visualizar este livro, então posso responder perguntas, mas não alterar nada.', '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', diff --git a/shared/src/i18n/ca/assistant.ts b/shared/src/i18n/ca/assistant.ts index a25c97a54..9c75bccc6 100644 --- a/shared/src/i18n/ca/assistant.ts +++ b/shared/src/i18n/ca/assistant.ts @@ -75,6 +75,12 @@ const assistant: TranslationStrings = { 'Els teus missatges i els detalls del viatge que llegeix l’assistent, com llocs, dies i reserves.', 'assistant.privacy.notShared': 'Ni la teva contrasenya, ni els teus fitxers, ni els teus altres viatges.', 'assistant.privacy.provider': '{provider} les tracta segons les seves pròpies condicions de privadesa.', + 'assistant.privacy.bannerBook': + 'Els teus missatges i les dades de la travesia que llegeix l’assistent s’envien a {provider}.', + 'assistant.privacy.sharedBook': + 'Els teus missatges i el que l’assistent llegeix d’aquesta travesia, com les entrades, els llocs i les pàgines del llibre.', + 'assistant.privacy.notSharedBook': + 'Ni la teva contrasenya ni les teves altres travesies. La imatge d’una pàgina només s’envia quan tu ho permets.', 'assistant.empty.title': 'Què planifiquem?', 'assistant.empty.readOnly': 'Només pots veure aquest viatge, així que puc respondre preguntes però no canviar res.', 'assistant.suggest.planFirstDays': 'Planifica’m els primers dies', diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index 56826b172..6e07c7111 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -910,6 +910,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Només pots veure aquest llibre, així que puc respondre preguntes però no canviar res.', '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', diff --git a/shared/src/i18n/cs/assistant.ts b/shared/src/i18n/cs/assistant.ts index c9aae7bd6..3172acd25 100644 --- a/shared/src/i18n/cs/assistant.ts +++ b/shared/src/i18n/cs/assistant.ts @@ -74,6 +74,11 @@ const assistant: TranslationStrings = { 'Tvoje zprávy a podrobnosti o cestě, které asistent čte, například místa, dny a rezervace.', 'assistant.privacy.notShared': 'Ne tvoje heslo, tvoje soubory ani tvoje další cesty.', 'assistant.privacy.provider': '{provider} s nimi nakládá podle vlastních zásad ochrany soukromí.', + 'assistant.privacy.bannerBook': 'Tvoje zprávy a data cestovního deníku, která asistent čte, jdou do {provider}.', + 'assistant.privacy.sharedBook': + 'Tvoje zprávy a to, co asistent z tohoto cestovního deníku čte, například záznamy, místa a stránky knihy.', + 'assistant.privacy.notSharedBook': + 'Ne tvoje heslo ani tvoje další cestovní deníky. Obrázek stránky odejde, až když to povolíš.', 'assistant.empty.title': 'Co budeme plánovat?', 'assistant.empty.readOnly': 'Tuhle cestu můžeš jen prohlížet, takže odpovím na otázky, ale nic nezměním.', 'assistant.suggest.planFirstDays': 'Naplánuj mi první dny', diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index 2738b9e47..e66383edc 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -920,6 +920,7 @@ const journey: TranslationStrings = { '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.readOnly': 'Tuhle knihu můžeš jen prohlížet, takže odpovím na otázky, ale nic nezměním.', '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', diff --git a/shared/src/i18n/de/assistant.ts b/shared/src/i18n/de/assistant.ts index acb240136..018f784a4 100644 --- a/shared/src/i18n/de/assistant.ts +++ b/shared/src/i18n/de/assistant.ts @@ -75,6 +75,12 @@ const assistant: TranslationStrings = { 'Deine Nachrichten und die Reisedaten, die der Assistent liest, etwa Orte, Tage und Buchungen.', 'assistant.privacy.notShared': 'Nicht dein Passwort, nicht deine Dateien und nicht deine anderen Reisen.', 'assistant.privacy.provider': '{provider} verarbeitet das nach den eigenen Datenschutzbedingungen.', + 'assistant.privacy.bannerBook': + 'Deine Nachrichten und die Journey-Daten, die der Assistent liest, gehen an {provider}.', + 'assistant.privacy.sharedBook': + 'Deine Nachrichten und was der Assistent aus dieser Journey liest, etwa Einträge, Orte und die Seiten des Buchs.', + 'assistant.privacy.notSharedBook': + 'Nicht dein Passwort und nicht deine anderen Journeys. Ein Bild einer Seite geht erst, wenn du es erlaubst.', 'assistant.empty.title': 'Was planen wir?', 'assistant.empty.readOnly': 'Du kannst diese Reise nur ansehen, deshalb beantworte ich Fragen, ändere aber nichts.', 'assistant.suggest.planFirstDays': 'Plane mir die ersten Tage', diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index 270cae8ee..27b1ab8a4 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -916,6 +916,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Du kannst dieses Buch nur ansehen, deshalb beantworte ich Fragen, ändere aber nichts.', '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', diff --git a/shared/src/i18n/en/assistant.ts b/shared/src/i18n/en/assistant.ts index c65c4443b..b84eaf102 100644 --- a/shared/src/i18n/en/assistant.ts +++ b/shared/src/i18n/en/assistant.ts @@ -72,6 +72,11 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': 'Your messages and the trip details the assistant reads, like places, days and bookings.', 'assistant.privacy.notShared': 'Not your password, your files or your other trips.', 'assistant.privacy.provider': '{provider} handles this under its own privacy terms.', + 'assistant.privacy.bannerBook': 'Your messages and the journey data the assistant reads go to {provider}.', + 'assistant.privacy.sharedBook': + 'Your messages and what the assistant reads of this journey, like its entries, places and book pages.', + 'assistant.privacy.notSharedBook': + 'Not your password or your other journeys. A picture of a page goes only once you allow it.', 'assistant.empty.title': 'What are we planning?', 'assistant.empty.readOnly': 'You can only view this trip, so I can answer questions but not change anything.', 'assistant.suggest.planFirstDays': 'Plan the first days for me', diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index 9a5dcbc80..6b658ab3f 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -896,6 +896,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'You can only view this book, so I can answer questions but not change anything.', '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', diff --git a/shared/src/i18n/es/assistant.ts b/shared/src/i18n/es/assistant.ts index a89936785..6a940a3df 100644 --- a/shared/src/i18n/es/assistant.ts +++ b/shared/src/i18n/es/assistant.ts @@ -73,6 +73,12 @@ const assistant: TranslationStrings = { 'Tus mensajes y los detalles del viaje que lee el asistente, como lugares, días y reservas.', 'assistant.privacy.notShared': 'Ni tu contraseña, ni tus archivos, ni tus otros viajes.', 'assistant.privacy.provider': '{provider} los trata según sus propias condiciones de privacidad.', + 'assistant.privacy.bannerBook': + 'Tus mensajes y los datos de la travesía que lee el asistente se envían a {provider}.', + 'assistant.privacy.sharedBook': + 'Tus mensajes y lo que el asistente lee de esta travesía, como las entradas, los lugares y las páginas del libro.', + 'assistant.privacy.notSharedBook': + 'Ni tu contraseña ni tus otras travesías. La imagen de una página solo se envía cuando tú lo permites.', 'assistant.empty.title': '¿Qué planificamos?', 'assistant.empty.readOnly': 'Solo puedes ver este viaje, así que puedo responder preguntas pero no cambiar nada.', 'assistant.suggest.planFirstDays': 'Planifícame los primeros días', diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index ff546bbc7..bbb43ab28 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -910,6 +910,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Solo puedes ver este libro, así que puedo responder preguntas pero no cambiar nada.', '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', diff --git a/shared/src/i18n/et/assistant.ts b/shared/src/i18n/et/assistant.ts index a0997c69c..0ce79bd4f 100644 --- a/shared/src/i18n/et/assistant.ts +++ b/shared/src/i18n/et/assistant.ts @@ -74,6 +74,12 @@ const assistant: TranslationStrings = { 'Sinu sõnumid ja reisi üksikasjad, mida assistent loeb, näiteks kohad, päevad ja broneeringud.', 'assistant.privacy.notShared': 'Mitte sinu parool, failid ega teised reisid.', 'assistant.privacy.provider': '{provider} töötleb neid oma privaatsustingimuste järgi.', + 'assistant.privacy.bannerBook': + 'Sinu sõnumid ja reisiloo andmed, mida assistent loeb, saadetakse teenusele {provider}.', + 'assistant.privacy.sharedBook': + 'Sinu sõnumid ja see, mida assistent sellest reisiloost loeb, näiteks sissekanded, kohad ja raamatu lehed.', + 'assistant.privacy.notSharedBook': + 'Mitte sinu parool ega teised reisilood. Lehe pilt saadetakse alles siis, kui sa selle lubad.', 'assistant.empty.title': 'Mida me planeerime?', 'assistant.empty.readOnly': 'Sa saad seda reisi ainult vaadata, seega saan küsimustele vastata, kuid mitte midagi muuta.', diff --git a/shared/src/i18n/et/journey.ts b/shared/src/i18n/et/journey.ts index a37abcd60..40e7e7544 100644 --- a/shared/src/i18n/et/journey.ts +++ b/shared/src/i18n/et/journey.ts @@ -900,6 +900,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Sa saad seda raamatut ainult vaadata, seega saan küsimustele vastata, kuid mitte midagi muuta.', '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', diff --git a/shared/src/i18n/fr/assistant.ts b/shared/src/i18n/fr/assistant.ts index 27624756e..f57a6fc2d 100644 --- a/shared/src/i18n/fr/assistant.ts +++ b/shared/src/i18n/fr/assistant.ts @@ -74,6 +74,12 @@ const assistant: TranslationStrings = { 'Vos messages et les détails du voyage que lit l’assistant, comme les lieux, les jours et les réservations.', 'assistant.privacy.notShared': 'Ni votre mot de passe, ni vos fichiers, ni vos autres voyages.', 'assistant.privacy.provider': '{provider} traite ces données selon ses propres règles de confidentialité.', + 'assistant.privacy.bannerBook': + 'Vos messages et les données du journal de voyage que lit l’assistant sont envoyés à {provider}.', + 'assistant.privacy.sharedBook': + 'Vos messages et ce que l’assistant lit de ce journal de voyage, comme les entrées, les lieux et les pages du livre.', + 'assistant.privacy.notSharedBook': + 'Ni votre mot de passe, ni vos autres journaux de voyage. L’image d’une page n’est envoyée qu’une fois que vous l’autorisez.', 'assistant.empty.title': 'Qu’est-ce qu’on planifie ?', 'assistant.empty.readOnly': 'Vous pouvez seulement consulter ce voyage : je peux répondre aux questions, sans rien modifier.', diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index 836402732..f07d02adb 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -908,6 +908,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Vous pouvez seulement consulter ce livre : je peux répondre aux questions, sans rien modifier.', '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', diff --git a/shared/src/i18n/gr/assistant.ts b/shared/src/i18n/gr/assistant.ts index ac43c0592..3b3a5ea58 100644 --- a/shared/src/i18n/gr/assistant.ts +++ b/shared/src/i18n/gr/assistant.ts @@ -76,6 +76,12 @@ const assistant: TranslationStrings = { 'Τα μηνύματά σας και οι λεπτομέρειες του ταξιδιού που διαβάζει ο βοηθός, όπως μέρη, ημέρες και κρατήσεις.', 'assistant.privacy.notShared': 'Όχι ο κωδικός σας, τα αρχεία σας ή τα άλλα ταξίδια σας.', 'assistant.privacy.provider': 'Το {provider} τα χειρίζεται σύμφωνα με τους δικούς του όρους απορρήτου.', + 'assistant.privacy.bannerBook': + 'Τα μηνύματά σας και τα δεδομένα του ημερολογίου ταξιδιού που διαβάζει ο βοηθός πηγαίνουν στο {provider}.', + 'assistant.privacy.sharedBook': + 'Τα μηνύματά σας και όσα διαβάζει ο βοηθός από αυτό το ημερολόγιο ταξιδιού, όπως καταχωρίσεις, μέρη και σελίδες του βιβλίου.', + 'assistant.privacy.notSharedBook': + 'Όχι ο κωδικός σας ή τα άλλα ημερολόγια ταξιδιού σας. Η εικόνα μιας σελίδας στέλνεται μόνο αφού το επιτρέψετε.', 'assistant.empty.title': 'Τι σχεδιάζουμε;', 'assistant.empty.readOnly': 'Μπορείτε μόνο να δείτε αυτό το ταξίδι, οπότε απαντώ σε ερωτήσεις αλλά δεν αλλάζω τίποτα.', diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index 32ea6c26c..08825d2c7 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -918,6 +918,8 @@ const journey: TranslationStrings = { 'journey.studio.assistant.toggleHint': 'Σχεδιάστε το βιβλίο μαζί με τον βοηθό', 'journey.studio.assistant.panel': 'Βοηθός βιβλίου', 'journey.studio.assistant.welcome': 'Πώς πρέπει να είναι το βιβλίο;', + 'journey.studio.assistant.readOnly': + 'Μπορείτε μόνο να δείτε αυτό το βιβλίο, οπότε απαντώ σε ερωτήσεις αλλά δεν αλλάζω τίποτα.', 'journey.studio.assistant.notice': 'Ο βοηθός μπορεί να δει τη σελίδα στην οποία δουλεύετε. Μόνο αυτή η σελίδα στέλνεται στο {provider}, ως εικόνα που δείχνει μόνο τις φωτογραφίες που προσθέσατε εσείς. Δεν είναι δυνατό να αναγνωριστεί ποιος εμφανίζεται στις φωτογραφίες σας.', 'journey.studio.assistant.noticeAllow': 'Εμφάνιση σελίδων', diff --git a/shared/src/i18n/hu/assistant.ts b/shared/src/i18n/hu/assistant.ts index a03d6db1d..aa94a9bf7 100644 --- a/shared/src/i18n/hu/assistant.ts +++ b/shared/src/i18n/hu/assistant.ts @@ -75,6 +75,12 @@ const assistant: TranslationStrings = { 'Az üzeneteid és az utazás részletei, amelyeket az asszisztens elolvas, például helyek, napok és foglalások.', 'assistant.privacy.notShared': 'A jelszavad, a fájljaid és a többi utazásod nem.', 'assistant.privacy.provider': 'A(z) {provider} ezt a saját adatvédelmi feltételei szerint kezeli.', + 'assistant.privacy.bannerBook': + 'Az üzeneteid és az útinapló adatai, amelyeket az asszisztens elolvas, ide kerülnek: {provider}.', + 'assistant.privacy.sharedBook': + 'Az üzeneteid és amit az asszisztens ebből az útinaplóból elolvas, például bejegyzések, helyek és a könyv oldalai.', + 'assistant.privacy.notSharedBook': + 'A jelszavad és a többi útinaplód nem. Egy oldal képe csak akkor kerül el, ha engedélyezed.', 'assistant.empty.title': 'Mit tervezünk?', 'assistant.empty.readOnly': 'Ezt az utazást csak megtekintheted, ezért kérdésekre válaszolok, de semmit nem módosítok.', diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index 1e11bc013..e70b4c29e 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -906,6 +906,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Ezt a könyvet csak megtekintheted, ezért kérdésekre válaszolok, de semmit nem módosítok.', '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', diff --git a/shared/src/i18n/id/assistant.ts b/shared/src/i18n/id/assistant.ts index 4737ca481..912a28bb6 100644 --- a/shared/src/i18n/id/assistant.ts +++ b/shared/src/i18n/id/assistant.ts @@ -72,6 +72,11 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': 'Pesanmu dan detail perjalanan yang dibaca asisten, seperti tempat, hari, dan pemesanan.', 'assistant.privacy.notShared': 'Bukan kata sandimu, file-mu, atau perjalananmu yang lain.', 'assistant.privacy.provider': '{provider} menanganinya sesuai ketentuan privasinya sendiri.', + 'assistant.privacy.bannerBook': 'Pesanmu dan data jurnal perjalanan yang dibaca asisten dikirim ke {provider}.', + 'assistant.privacy.sharedBook': + 'Pesanmu dan apa yang dibaca asisten dari jurnal perjalanan ini, seperti catatan, tempat, dan halaman buku.', + 'assistant.privacy.notSharedBook': + 'Bukan kata sandimu atau jurnal perjalananmu yang lain. Gambar sebuah halaman baru dikirim setelah kamu mengizinkannya.', 'assistant.empty.title': 'Kita merencanakan apa?', 'assistant.empty.readOnly': 'Kamu hanya bisa melihat perjalanan ini, jadi aku bisa menjawab pertanyaan tapi tidak bisa mengubah apa pun.', diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index 094179202..e1a265067 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -882,6 +882,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Kamu hanya bisa melihat buku ini, jadi aku bisa menjawab pertanyaan tapi tidak bisa mengubah apa pun.', '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', diff --git a/shared/src/i18n/it/assistant.ts b/shared/src/i18n/it/assistant.ts index 5528b9dda..f8625ea3a 100644 --- a/shared/src/i18n/it/assistant.ts +++ b/shared/src/i18n/it/assistant.ts @@ -75,6 +75,12 @@ const assistant: TranslationStrings = { 'I tuoi messaggi e i dettagli del viaggio letti dall’assistente, come luoghi, giorni e prenotazioni.', 'assistant.privacy.notShared': 'Non la tua password, i tuoi file né gli altri tuoi viaggi.', 'assistant.privacy.provider': '{provider} li tratta secondo la propria informativa sulla privacy.', + 'assistant.privacy.bannerBook': + 'I tuoi messaggi e i dati del diario di viaggio letti dall’assistente vengono inviati a {provider}.', + 'assistant.privacy.sharedBook': + 'I tuoi messaggi e ciò che l’assistente legge di questo diario di viaggio, come voci, luoghi e pagine del libro.', + 'assistant.privacy.notSharedBook': + 'Non la tua password né gli altri tuoi diari di viaggio. L’immagine di una pagina viene inviata solo dopo che l’hai permesso.', 'assistant.empty.title': 'Cosa pianifichiamo?', 'assistant.empty.readOnly': 'Puoi solo visualizzare questo viaggio, quindi posso rispondere alle domande ma non modificare nulla.', diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index 6f8c226c9..86e48539f 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -909,6 +909,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Puoi solo visualizzare questo libro, quindi posso rispondere alle domande ma non modificare nulla.', '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', diff --git a/shared/src/i18n/ja/assistant.ts b/shared/src/i18n/ja/assistant.ts index c80567018..faaa569aa 100644 --- a/shared/src/i18n/ja/assistant.ts +++ b/shared/src/i18n/ja/assistant.ts @@ -72,6 +72,11 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': 'メッセージと、アシスタントが読み取る場所、日程、予約などの旅行の詳細。', 'assistant.privacy.notShared': 'パスワード、ファイル、ほかの旅行は共有されません。', 'assistant.privacy.provider': '{provider} は独自のプライバシー規約に基づいてこれを取り扱います。', + 'assistant.privacy.bannerBook': 'メッセージと、アシスタントが読み取る日記のデータは {provider} に送信されます。', + 'assistant.privacy.sharedBook': + 'メッセージと、アシスタントがこの日記から読み取るエントリー、場所、本のページなどの内容。', + 'assistant.privacy.notSharedBook': + 'パスワードとほかの日記は共有されません。ページの画像は、あなたが許可したときにだけ送信されます。', 'assistant.empty.title': '何を計画しましょうか?', 'assistant.empty.readOnly': 'この旅行は閲覧のみなので、質問には答えられますが、変更はできません。', 'assistant.suggest.planFirstDays': '最初の数日を計画して', diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 6d976a21c..6eda3aab7 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -868,6 +868,7 @@ const journey: TranslationStrings = { 'journey.studio.assistant.toggleHint': 'アシスタントと一緒に本をデザイン', 'journey.studio.assistant.panel': '本のアシスタント', 'journey.studio.assistant.welcome': 'どんな本にしますか?', + 'journey.studio.assistant.readOnly': 'この本は閲覧のみなので、質問には答えられますが、変更はできません。', 'journey.studio.assistant.notice': 'アシスタントは作業中のページを見ることができます。{provider} に送られるのはそのページだけで、あなた自身が追加した写真だけが写った画像として送られます。写真に誰が写っているかは判別できません。', 'journey.studio.assistant.noticeAllow': 'ページを見せる', diff --git a/shared/src/i18n/ko/assistant.ts b/shared/src/i18n/ko/assistant.ts index 759fc15fe..1a8f926cb 100644 --- a/shared/src/i18n/ko/assistant.ts +++ b/shared/src/i18n/ko/assistant.ts @@ -72,6 +72,10 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': '메시지와 어시스턴트가 읽는 장소, 일정, 예약 같은 여행 정보.', 'assistant.privacy.notShared': '비밀번호, 파일, 다른 여행은 공유되지 않습니다.', 'assistant.privacy.provider': '{provider}은(는) 자체 개인정보 처리방침에 따라 이를 처리합니다.', + 'assistant.privacy.bannerBook': '메시지와 어시스턴트가 읽는 여행 기록 데이터는 {provider}(으)로 전송됩니다.', + 'assistant.privacy.sharedBook': '메시지와 어시스턴트가 이 여행 기록에서 읽는 항목, 장소, 책 페이지 같은 내용.', + 'assistant.privacy.notSharedBook': + '비밀번호와 다른 여행 기록은 공유되지 않습니다. 페이지 이미지는 허용한 뒤에만 전송됩니다.', 'assistant.empty.title': '무엇을 계획할까요?', 'assistant.empty.readOnly': '이 여행은 보기만 할 수 있어서 질문에는 답하지만 아무것도 바꿀 수 없어요.', 'assistant.suggest.planFirstDays': '처음 며칠 일정을 짜 줘', diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index c54c96bfe..ee9e51b07 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -864,6 +864,7 @@ const journey: TranslationStrings = { 'journey.studio.assistant.toggleHint': '어시스턴트와 함께 책 디자인하기', 'journey.studio.assistant.panel': '책 어시스턴트', 'journey.studio.assistant.welcome': '책을 어떻게 꾸밀까요?', + 'journey.studio.assistant.readOnly': '이 책은 보기만 할 수 있어서 질문에는 답하지만 아무것도 바꿀 수 없어요.', 'journey.studio.assistant.notice': '어시스턴트가 작업 중인 페이지를 볼 수 있습니다. {provider}(으)로는 그 페이지만, 직접 추가한 사진만 보이는 이미지로 전송됩니다. 사진에 누가 있는지는 감지할 수 없습니다.', 'journey.studio.assistant.noticeAllow': '페이지 보여주기', diff --git a/shared/src/i18n/nl/assistant.ts b/shared/src/i18n/nl/assistant.ts index e8c1436ba..d575c9769 100644 --- a/shared/src/i18n/nl/assistant.ts +++ b/shared/src/i18n/nl/assistant.ts @@ -74,6 +74,12 @@ const assistant: TranslationStrings = { 'Je berichten en de reisgegevens die de assistent leest, zoals plekken, dagen en boekingen.', 'assistant.privacy.notShared': 'Niet je wachtwoord, je bestanden of je andere reizen.', 'assistant.privacy.provider': '{provider} verwerkt dit volgens de eigen privacyvoorwaarden.', + 'assistant.privacy.bannerBook': + 'Je berichten en de gegevens van het reisverslag die de assistent leest, gaan naar {provider}.', + 'assistant.privacy.sharedBook': + 'Je berichten en wat de assistent van dit reisverslag leest, zoals de vermeldingen, plekken en pagina’s van het boek.', + 'assistant.privacy.notSharedBook': + 'Niet je wachtwoord of je andere reisverslagen. Een afbeelding van een pagina gaat pas mee als jij dat toestaat.', 'assistant.empty.title': 'Wat gaan we plannen?', 'assistant.empty.readOnly': 'Je kunt deze reis alleen bekijken, dus ik beantwoord vragen maar verander niets.', 'assistant.suggest.planFirstDays': 'Plan de eerste dagen voor me', diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index 4f5327cfd..1deae431a 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -910,6 +910,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Je kunt dit boek alleen bekijken, dus ik beantwoord vragen maar verander niets.', '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', diff --git a/shared/src/i18n/pl/assistant.ts b/shared/src/i18n/pl/assistant.ts index 29df24467..2de12001a 100644 --- a/shared/src/i18n/pl/assistant.ts +++ b/shared/src/i18n/pl/assistant.ts @@ -76,6 +76,12 @@ const assistant: TranslationStrings = { 'Twoje wiadomości i szczegóły podróży, które czyta asystent, np. miejsca, dni i rezerwacje.', 'assistant.privacy.notShared': 'Nie Twoje hasło, pliki ani inne podróże.', 'assistant.privacy.provider': '{provider} przetwarza je zgodnie z własną polityką prywatności.', + 'assistant.privacy.bannerBook': + 'Twoje wiadomości i dane dziennika podróży, które czyta asystent, trafiają do {provider}.', + 'assistant.privacy.sharedBook': + 'Twoje wiadomości i to, co asystent czyta z tego dziennika podróży, np. wpisy, miejsca i strony książki.', + 'assistant.privacy.notSharedBook': + 'Nie Twoje hasło ani inne dzienniki podróży. Obraz strony trafia dalej dopiero wtedy, gdy na to pozwolisz.', 'assistant.empty.title': 'Co planujemy?', 'assistant.empty.readOnly': 'Możesz tylko przeglądać tę podróż, więc odpowiem na pytania, ale niczego nie zmienię.', 'assistant.suggest.planFirstDays': 'Zaplanuj mi pierwsze dni', diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index fa7c57579..e3ba18c0d 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -951,6 +951,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Możesz tylko przeglądać tę książkę, więc odpowiem na pytania, ale niczego nie zmienię.', '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', diff --git a/shared/src/i18n/ru/assistant.ts b/shared/src/i18n/ru/assistant.ts index a31f2c517..7b65aa9df 100644 --- a/shared/src/i18n/ru/assistant.ts +++ b/shared/src/i18n/ru/assistant.ts @@ -75,6 +75,12 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': 'Ваши сообщения и данные поездки, которые читает ассистент: места, дни и бронирования.', 'assistant.privacy.notShared': 'Не ваш пароль, не ваши файлы и не другие ваши поездки.', 'assistant.privacy.provider': '{provider} обрабатывает их по своим правилам конфиденциальности.', + 'assistant.privacy.bannerBook': + 'Ваши сообщения и данные путешествия, которые читает ассистент, передаются в {provider}.', + 'assistant.privacy.sharedBook': + 'Ваши сообщения и то, что ассистент читает в этом путешествии: записи, места и страницы книги.', + 'assistant.privacy.notSharedBook': + 'Не ваш пароль и не другие ваши путешествия. Изображение страницы отправляется только после вашего разрешения.', 'assistant.empty.title': 'Что планируем?', 'assistant.empty.readOnly': 'Эту поездку вы можете только просматривать, поэтому я отвечу на вопросы, но ничего не изменю.', diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 76c3c5f21..b5764b3ca 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -950,6 +950,8 @@ const journey: TranslationStrings = { 'journey.studio.assistant.toggleHint': 'Создавайте книгу вместе с ассистентом', 'journey.studio.assistant.panel': 'Ассистент книги', 'journey.studio.assistant.welcome': 'Какой должна быть книга?', + 'journey.studio.assistant.readOnly': + 'Эту книгу вы можете только просматривать, поэтому я отвечу на вопросы, но ничего не изменю.', 'journey.studio.assistant.notice': 'Ассистент может посмотреть страницу, над которой вы работаете. В {provider} отправляется только эта страница, в виде изображения, на котором видны лишь фото, добавленные вами. Определить, кто изображён на ваших фото, невозможно.', 'journey.studio.assistant.noticeAllow': 'Показывать страницы', diff --git a/shared/src/i18n/sk/assistant.ts b/shared/src/i18n/sk/assistant.ts index 762f51dc4..dedaa2062 100644 --- a/shared/src/i18n/sk/assistant.ts +++ b/shared/src/i18n/sk/assistant.ts @@ -74,6 +74,11 @@ const assistant: TranslationStrings = { 'Tvoje správy a podrobnosti o ceste, ktoré asistent číta, napríklad miesta, dni a rezervácie.', 'assistant.privacy.notShared': 'Nie tvoje heslo, tvoje súbory ani tvoje ďalšie cesty.', 'assistant.privacy.provider': '{provider} s tým nakladá podľa vlastných podmienok ochrany súkromia.', + 'assistant.privacy.bannerBook': 'Tvoje správy a údaje cestovného denníka, ktoré asistent číta, idú do {provider}.', + 'assistant.privacy.sharedBook': + 'Tvoje správy a to, čo asistent z tohto cestovného denníka číta, napríklad záznamy, miesta a strany knihy.', + 'assistant.privacy.notSharedBook': + 'Nie tvoje heslo ani tvoje ďalšie cestovné denníky. Obrázok strany odíde, až keď to povolíš.', 'assistant.empty.title': 'Čo plánujeme?', 'assistant.empty.readOnly': 'Túto cestu môžeš len prezerať, takže odpoviem na otázky, ale nič nezmením.', 'assistant.suggest.planFirstDays': 'Naplánuj mi prvé dni', diff --git a/shared/src/i18n/sk/journey.ts b/shared/src/i18n/sk/journey.ts index c8af0305f..4e97b3f9e 100644 --- a/shared/src/i18n/sk/journey.ts +++ b/shared/src/i18n/sk/journey.ts @@ -926,6 +926,7 @@ const journey: TranslationStrings = { '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.readOnly': 'Túto knihu môžeš len prezerať, takže odpoviem na otázky, ale nič nezmením.', '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', diff --git a/shared/src/i18n/sv/assistant.ts b/shared/src/i18n/sv/assistant.ts index 742151606..a8c894529 100644 --- a/shared/src/i18n/sv/assistant.ts +++ b/shared/src/i18n/sv/assistant.ts @@ -75,6 +75,12 @@ const assistant: TranslationStrings = { 'Dina meddelanden och de resedetaljer som assistenten läser, som platser, dagar och bokningar.', 'assistant.privacy.notShared': 'Inte ditt lösenord, dina filer eller dina andra resor.', 'assistant.privacy.provider': '{provider} hanterar detta enligt sina egna integritetsvillkor.', + 'assistant.privacy.bannerBook': + 'Dina meddelanden och de uppgifter ur resedagboken som assistenten läser skickas till {provider}.', + 'assistant.privacy.sharedBook': + 'Dina meddelanden och det assistenten läser i den här resedagboken, som inlägg, platser och bokens sidor.', + 'assistant.privacy.notSharedBook': + 'Inte ditt lösenord eller dina andra resedagböcker. En bild av en sida skickas först när du tillåter det.', 'assistant.empty.title': 'Vad planerar vi?', 'assistant.empty.readOnly': 'Du kan bara visa den här resan, så jag kan svara på frågor men inte ändra något.', 'assistant.suggest.planFirstDays': 'Planera de första dagarna åt mig', diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index 75654a466..83207cd3b 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -900,6 +900,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Du kan bara visa den här boken, så jag kan svara på frågor men inte ändra något.', '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', diff --git a/shared/src/i18n/th/assistant.ts b/shared/src/i18n/th/assistant.ts index c547dcb30..9ed60e615 100644 --- a/shared/src/i18n/th/assistant.ts +++ b/shared/src/i18n/th/assistant.ts @@ -71,6 +71,11 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': 'ข้อความของคุณและรายละเอียดทริปที่ผู้ช่วยอ่าน เช่น สถานที่ วัน และการจอง', 'assistant.privacy.notShared': 'ไม่รวมรหัสผ่าน ไฟล์ และทริปอื่นของคุณ', 'assistant.privacy.provider': '{provider} จัดการข้อมูลนี้ตามเงื่อนไขความเป็นส่วนตัวของตนเอง', + 'assistant.privacy.bannerBook': 'ข้อความของคุณและข้อมูลการเดินทางที่ผู้ช่วยอ่านจะถูกส่งไปยัง {provider}', + 'assistant.privacy.sharedBook': + 'ข้อความของคุณและสิ่งที่ผู้ช่วยอ่านจากการเดินทางนี้ เช่น บันทึก สถานที่ และหน้าในหนังสือ', + 'assistant.privacy.notSharedBook': + 'ไม่รวมรหัสผ่านและการเดินทางอื่นของคุณ ภาพของหน้าจะถูกส่งก็ต่อเมื่อคุณอนุญาตแล้วเท่านั้น', 'assistant.empty.title': 'เราจะวางแผนอะไรกันดี', 'assistant.empty.readOnly': 'คุณดูทริปนี้ได้อย่างเดียว ฉันจึงตอบคำถามได้แต่เปลี่ยนแปลงอะไรไม่ได้', 'assistant.suggest.planFirstDays': 'ช่วยวางแผนวันแรก ๆ ให้หน่อย', diff --git a/shared/src/i18n/th/journey.ts b/shared/src/i18n/th/journey.ts index 56416d674..ec36529bc 100644 --- a/shared/src/i18n/th/journey.ts +++ b/shared/src/i18n/th/journey.ts @@ -857,6 +857,7 @@ const journey: TranslationStrings = { 'journey.studio.assistant.toggleHint': 'ออกแบบหนังสือร่วมกับผู้ช่วย', 'journey.studio.assistant.panel': 'ผู้ช่วยหนังสือ', 'journey.studio.assistant.welcome': 'อยากให้หนังสือออกมาเป็นแบบไหน?', + 'journey.studio.assistant.readOnly': 'คุณดูหนังสือเล่มนี้ได้อย่างเดียว ฉันจึงตอบคำถามได้แต่เปลี่ยนแปลงอะไรไม่ได้', 'journey.studio.assistant.notice': 'ผู้ช่วยสามารถดูหน้าที่คุณกำลังทำอยู่ได้ มีเพียงหน้านั้นที่ส่งไปยัง {provider} ในรูปแบบภาพที่แสดงเฉพาะรูปภาพที่คุณเพิ่มเอง ไม่สามารถตรวจจับได้ว่าใครอยู่ในรูปภาพของคุณ', 'journey.studio.assistant.noticeAllow': 'แสดงหน้าให้ดู', diff --git a/shared/src/i18n/tr/assistant.ts b/shared/src/i18n/tr/assistant.ts index 0cf0cd1f4..bb88e223f 100644 --- a/shared/src/i18n/tr/assistant.ts +++ b/shared/src/i18n/tr/assistant.ts @@ -73,6 +73,12 @@ const assistant: TranslationStrings = { 'Mesajların ve asistanın okuduğu gezi bilgileri, örneğin yerler, günler ve rezervasyonlar.', 'assistant.privacy.notShared': 'Şifren, dosyaların ve diğer gezilerin paylaşılmaz.', 'assistant.privacy.provider': '{provider} bunu kendi gizlilik koşullarına göre işler.', + 'assistant.privacy.bannerBook': + 'Mesajların ve asistanın okuduğu seyahat günlüğü verileri {provider} hizmetine gider.', + 'assistant.privacy.sharedBook': + 'Mesajların ve asistanın bu seyahat günlüğünden okudukları, örneğin kayıtlar, yerler ve kitabın sayfaları.', + 'assistant.privacy.notSharedBook': + 'Şifren ve diğer seyahat günlüklerin paylaşılmaz. Bir sayfanın görüntüsü ancak sen izin verdikten sonra gider.', 'assistant.empty.title': 'Ne planlıyoruz?', 'assistant.empty.readOnly': 'Bu geziyi yalnızca görüntüleyebiliyorsun, bu yüzden soruları yanıtlarım ama bir şey değiştiremem.', diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index b2156c3dc..e3bb85429 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -898,6 +898,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Bu kitabı yalnızca görüntüleyebiliyorsun, bu yüzden soruları yanıtlarım ama bir şey değiştiremem.', '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', diff --git a/shared/src/i18n/uk/assistant.ts b/shared/src/i18n/uk/assistant.ts index 03e4b647e..3811b5a4a 100644 --- a/shared/src/i18n/uk/assistant.ts +++ b/shared/src/i18n/uk/assistant.ts @@ -75,6 +75,12 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': 'Ваші повідомлення й дані подорожі, які читає асистент, як-от місця, дні та бронювання.', 'assistant.privacy.notShared': 'Не ваш пароль, не ваші файли й не інші ваші подорожі.', 'assistant.privacy.provider': '{provider} обробляє це за власними умовами конфіденційності.', + 'assistant.privacy.bannerBook': + 'Ваші повідомлення й дані щоденника подорожі, які читає асистент, надсилаються до {provider}.', + 'assistant.privacy.sharedBook': + 'Ваші повідомлення й те, що асистент читає в цьому щоденнику подорожі, як-от записи, місця та сторінки книги.', + 'assistant.privacy.notSharedBook': + 'Не ваш пароль і не інші ваші щоденники подорожей. Зображення сторінки надсилається лише після вашого дозволу.', 'assistant.empty.title': 'Що плануємо?', 'assistant.empty.readOnly': 'Ви можете лише переглядати цю подорож, тож я відповідаю на запитання, але нічого не змінюю.', diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index c501be91a..d52a91ad4 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -957,6 +957,8 @@ const journey: TranslationStrings = { 'journey.studio.assistant.toggleHint': 'Створюйте книгу разом з асистентом', 'journey.studio.assistant.panel': 'Асистент книги', 'journey.studio.assistant.welcome': 'Якою має бути книга?', + 'journey.studio.assistant.readOnly': + 'Ви можете лише переглядати цю книгу, тож я відповідаю на запитання, але нічого не змінюю.', 'journey.studio.assistant.notice': 'Асистент може переглянути сторінку, над якою ви працюєте. До {provider} надсилається лише ця сторінка, як зображення, на якому видно тільки фото, додані вами. Визначити, хто є на ваших фото, неможливо.', 'journey.studio.assistant.noticeAllow': 'Показувати сторінки', diff --git a/shared/src/i18n/vi/assistant.ts b/shared/src/i18n/vi/assistant.ts index 3d02b4b46..fae02002b 100644 --- a/shared/src/i18n/vi/assistant.ts +++ b/shared/src/i18n/vi/assistant.ts @@ -72,6 +72,11 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': 'Tin nhắn của bạn và thông tin chuyến đi mà trợ lý đọc, như địa điểm, ngày và đặt chỗ.', 'assistant.privacy.notShared': 'Không bao gồm mật khẩu, tệp hay các chuyến đi khác của bạn.', 'assistant.privacy.provider': '{provider} xử lý dữ liệu này theo điều khoản bảo mật riêng của họ.', + 'assistant.privacy.bannerBook': 'Tin nhắn của bạn và dữ liệu hành trình mà trợ lý đọc sẽ được gửi đến {provider}.', + 'assistant.privacy.sharedBook': + 'Tin nhắn của bạn và những gì trợ lý đọc từ hành trình này, như các ghi chép, địa điểm và trang sách.', + 'assistant.privacy.notSharedBook': + 'Không bao gồm mật khẩu hay các hành trình khác của bạn. Ảnh của một trang chỉ được gửi sau khi bạn cho phép.', 'assistant.empty.title': 'Chúng ta lên kế hoạch gì đây?', 'assistant.empty.readOnly': 'Bạn chỉ có thể xem chuyến đi này, nên tôi có thể trả lời câu hỏi nhưng không thay đổi gì.', diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index b69a8c7cf..c2a586ba3 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -877,6 +877,8 @@ const journey: TranslationStrings = { '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.readOnly': + 'Bạn chỉ có thể xem cuốn sách này, nên tôi có thể trả lời câu hỏi nhưng không thay đổi gì.', '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', diff --git a/shared/src/i18n/zh-TW/assistant.ts b/shared/src/i18n/zh-TW/assistant.ts index 7c811c98f..35c35ab9c 100644 --- a/shared/src/i18n/zh-TW/assistant.ts +++ b/shared/src/i18n/zh-TW/assistant.ts @@ -70,6 +70,9 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': '你的訊息以及助理讀取的旅程資訊,例如地點、日程和預訂。', 'assistant.privacy.notShared': '不包括你的密碼、檔案和其他旅程。', 'assistant.privacy.provider': '{provider} 會依照其自身的隱私條款處理這些資料。', + 'assistant.privacy.bannerBook': '你的訊息以及助理讀取的旅程與書籍資料會傳送給 {provider}。', + 'assistant.privacy.sharedBook': '你的訊息以及助理從這段旅程讀取的內容,例如記錄、地點和書中的頁面。', + 'assistant.privacy.notSharedBook': '不包括你的密碼和其他旅程。頁面的圖片只有在你允許後才會傳送。', 'assistant.empty.title': '我們要規劃什麼?', 'assistant.empty.readOnly': '你只能檢視這趟旅程,所以我可以回答問題,但不能做任何變更。', 'assistant.suggest.planFirstDays': '幫我規劃前幾天', diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index 314c886d0..b3f4724c1 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -844,6 +844,7 @@ const journey: TranslationStrings = { 'journey.studio.assistant.toggleHint': '與助理一起設計這本書', 'journey.studio.assistant.panel': '書籍助理', 'journey.studio.assistant.welcome': '這本書想做成什麼樣子?', + 'journey.studio.assistant.readOnly': '你只能檢視這本書,所以我可以回答問題,但不能做任何變更。', 'journey.studio.assistant.notice': '助理可以查看你正在編輯的頁面。只有這一頁會以圖片形式傳送給 {provider},圖片中只顯示你自己新增的照片。無法辨識照片中的人物是誰。', 'journey.studio.assistant.noticeAllow': '允許查看頁面', diff --git a/shared/src/i18n/zh/assistant.ts b/shared/src/i18n/zh/assistant.ts index 176214e21..5cc552206 100644 --- a/shared/src/i18n/zh/assistant.ts +++ b/shared/src/i18n/zh/assistant.ts @@ -70,6 +70,9 @@ const assistant: TranslationStrings = { 'assistant.privacy.shared': '你的消息以及助手读取的旅行信息,例如地点、日程和预订。', 'assistant.privacy.notShared': '不包括你的密码、文件和其他旅行。', 'assistant.privacy.provider': '{provider} 会按照其自身的隐私条款处理这些数据。', + 'assistant.privacy.bannerBook': '你的消息以及助手读取的旅程数据会发送给 {provider}。', + 'assistant.privacy.sharedBook': '你的消息以及助手从这段旅程中读取的内容,例如记录、地点和书页。', + 'assistant.privacy.notSharedBook': '不包括你的密码和其他旅程。页面图片只有在你允许后才会发送。', 'assistant.empty.title': '我们要规划什么?', 'assistant.empty.readOnly': '你只能查看这次旅行,所以我可以回答问题,但不能做任何更改。', 'assistant.suggest.planFirstDays': '帮我规划前几天', diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index ec41ac301..9eb4dd5d7 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -844,6 +844,7 @@ const journey: TranslationStrings = { 'journey.studio.assistant.toggleHint': '与助手一起设计这本书', 'journey.studio.assistant.panel': '书籍助手', 'journey.studio.assistant.welcome': '这本书想做成什么样子?', + 'journey.studio.assistant.readOnly': '你只能查看这本书,所以我可以回答问题,但不能做任何更改。', 'journey.studio.assistant.notice': '助手可以查看你正在编辑的页面。只有这一页会以图片形式发送给 {provider},图片中只显示你自己添加的照片。无法识别照片中的人物是谁。', 'journey.studio.assistant.noticeAllow': '允许查看页面',