From 9e40092507e59b026d8d8049a8560c5f0b4eed6b Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 23 Oct 2026 01:45:16 +0000 Subject: [PATCH] fix(studio): the assistant column draws the page the server applied, and reads the user's role A page preview draws the spread the server applied with the journey's photos, texts and figures instead of applying the operations again without them. The Studio's welcome, privacy banner and header fit a journey chat: a viewer is told they can only look, the banner speaks of the book, and the trip notes button stays in the trip chat. Escape closes the header menu before it stops a reply, a single page renders at the full long edge, the status says a model reads pages only while its provider has not refused an image, and pointing at a page in a design card outlines it in the Studio. --- client/e2e/page-snapshot-webkit.spec.ts | 5 +- client/e2e/page-snapshot.spec.ts | 3 + client/e2e/support/pageSnapshotFlow.ts | 35 ++++- client/e2e/support/pageSnapshotHarness.ts | 14 +- .../components/Assistant/AssistantHeader.tsx | 39 ++--- .../Assistant/AssistantPrivacyBanner.tsx | 13 +- .../Assistant/AssistantSurface.test.tsx | 30 +++- .../components/Assistant/AssistantWelcome.tsx | 4 +- .../Assistant/AssistantWindow.test.tsx | 11 +- .../bookDesign/BookDesignSpreadStrip.tsx | 12 +- .../Assistant/useAssistantSuggestions.ts | 13 +- .../Studio/StudioAssistantPanel.test.tsx | 128 ++++++++++++++-- .../Studio/StudioAssistantPanel.tsx | 42 +++++- .../src/components/Studio/StudioSidebar.tsx | 4 +- .../components/Studio/pageSnapshot.test.ts | 8 +- client/src/components/Studio/pageSnapshot.ts | 13 +- .../Studio/useStudioAssistant.test.tsx | 19 ++- .../components/Studio/useStudioAssistant.ts | 30 ++-- .../Studio/useStudioPageHint.test.tsx | 139 ++++++++++++++++++ .../components/Studio/useStudioPageHint.ts | 36 +++++ .../shared/ContextMenu.extra.test.tsx | 22 ++- client/src/components/shared/ContextMenu.tsx | 14 +- client/src/store/studioStore.ts | 10 +- client/src/styles/studio-assistant.css | 7 + .../ai-connections/ai-connections.service.ts | 21 ++- server/src/nest/assistant/assistant-vision.ts | 25 ++-- .../ai-connections.service.test.ts | 28 ++++ .../nest/assistant/assistant-vision.test.ts | 57 +++++-- shared/src/ai/ai-connections.schema.ts | 3 + .../assistant/assistant-vision.schema.spec.ts | 10 +- .../src/assistant/assistant-vision.schema.ts | 17 ++- shared/src/i18n/ar/assistant.ts | 3 + shared/src/i18n/ar/journey.ts | 1 + shared/src/i18n/az/assistant.ts | 6 + shared/src/i18n/az/journey.ts | 2 + shared/src/i18n/br/assistant.ts | 5 + shared/src/i18n/br/journey.ts | 2 + shared/src/i18n/ca/assistant.ts | 6 + shared/src/i18n/ca/journey.ts | 2 + shared/src/i18n/cs/assistant.ts | 5 + shared/src/i18n/cs/journey.ts | 1 + shared/src/i18n/de/assistant.ts | 6 + shared/src/i18n/de/journey.ts | 2 + shared/src/i18n/en/assistant.ts | 5 + shared/src/i18n/en/journey.ts | 2 + shared/src/i18n/es/assistant.ts | 6 + shared/src/i18n/es/journey.ts | 2 + shared/src/i18n/et/assistant.ts | 6 + shared/src/i18n/et/journey.ts | 2 + shared/src/i18n/fr/assistant.ts | 6 + shared/src/i18n/fr/journey.ts | 2 + shared/src/i18n/gr/assistant.ts | 6 + shared/src/i18n/gr/journey.ts | 2 + shared/src/i18n/hu/assistant.ts | 6 + shared/src/i18n/hu/journey.ts | 2 + shared/src/i18n/id/assistant.ts | 5 + shared/src/i18n/id/journey.ts | 2 + shared/src/i18n/it/assistant.ts | 6 + shared/src/i18n/it/journey.ts | 2 + shared/src/i18n/ja/assistant.ts | 5 + shared/src/i18n/ja/journey.ts | 1 + shared/src/i18n/ko/assistant.ts | 4 + shared/src/i18n/ko/journey.ts | 1 + shared/src/i18n/nl/assistant.ts | 6 + shared/src/i18n/nl/journey.ts | 2 + shared/src/i18n/pl/assistant.ts | 6 + shared/src/i18n/pl/journey.ts | 2 + shared/src/i18n/ru/assistant.ts | 6 + shared/src/i18n/ru/journey.ts | 2 + shared/src/i18n/sk/assistant.ts | 5 + shared/src/i18n/sk/journey.ts | 1 + shared/src/i18n/sv/assistant.ts | 6 + shared/src/i18n/sv/journey.ts | 2 + shared/src/i18n/th/assistant.ts | 5 + shared/src/i18n/th/journey.ts | 1 + shared/src/i18n/tr/assistant.ts | 6 + shared/src/i18n/tr/journey.ts | 2 + shared/src/i18n/uk/assistant.ts | 6 + shared/src/i18n/uk/journey.ts | 2 + shared/src/i18n/vi/assistant.ts | 5 + shared/src/i18n/vi/journey.ts | 2 + shared/src/i18n/zh-TW/assistant.ts | 3 + shared/src/i18n/zh-TW/journey.ts | 1 + shared/src/i18n/zh/assistant.ts | 3 + shared/src/i18n/zh/journey.ts | 1 + 85 files changed, 857 insertions(+), 144 deletions(-) create mode 100644 client/src/components/Studio/useStudioPageHint.test.tsx create mode 100644 client/src/components/Studio/useStudioPageHint.ts 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': '允许查看页面',