-
{t('assistant.privacy.banner', { provider })}
+
+ {book ? t('assistant.privacy.bannerBook', { provider }) : t('assistant.privacy.banner', { provider })}
+
{details && (
- {t('assistant.privacy.shared')}
- {t('assistant.privacy.notShared')}
+ {book ? t('assistant.privacy.sharedBook') : t('assistant.privacy.shared')}
+ {book ? t('assistant.privacy.notSharedBook') : t('assistant.privacy.notShared')}
{t('assistant.privacy.provider', { provider })}
)}
diff --git a/client/src/components/Assistant/AssistantSurface.test.tsx b/client/src/components/Assistant/AssistantSurface.test.tsx
index 637dd015a..5f8c4bc53 100644
--- a/client/src/components/Assistant/AssistantSurface.test.tsx
+++ b/client/src/components/Assistant/AssistantSurface.test.tsx
@@ -1,11 +1,12 @@
/**
- * FE-AI-SURF-001 to FE-AI-SURF-008
+ * FE-AI-SURF-001 to FE-AI-SURF-009
*
* The chat on the real @assistant-ui runtime, fed from the assistant store:
* the empty chat with its suggestions, stored messages as the user sees
* them, sending from the composer, the composer holding back while a change
* waits for a decision, and a journey's chat under its own key, without the
- * trip's questions and booking upload.
+ * trip's questions and booking upload, read only for a viewer of the
+ * journey and in the book's words.
*/
import type { AiStatusResponse, AssistantMessage } from '@trek/shared';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -13,10 +14,12 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
+import { type JourneyDetail, useJourneyStore } from '../../store/journeyStore';
import AssistantSurface from './AssistantSurface';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
+const JOURNEYS = useJourneyStore.getState();
const AT = '2026-10-21T08:00:00.000Z';
const STATUS: AiStatusResponse = {
@@ -52,7 +55,10 @@ beforeAll(() => {
});
beforeEach(() => send.mockClear());
-afterEach(() => useAssistantStore.setState(INITIAL, true));
+afterEach(() => {
+ useAssistantStore.setState(INITIAL, true);
+ useJourneyStore.setState(JOURNEYS, true);
+});
describe('AssistantSurface', () => {
it('FE-AI-SURF-001: the empty chat asks what to plan and offers questions that change nothing for an unknown trip', async () => {
@@ -226,6 +232,8 @@ describe('AssistantSurface', () => {
// It asks about the book, not the plan.
expect(screen.getByText('What should the book look like?')).toBeInTheDocument();
expect(screen.queryByText('What are we planning?')).toBeNull();
+ // An owner of the journey, as the Studio reads one before the journey says otherwise.
+ expect(screen.queryByText(/You can only view/)).toBeNull();
expect(screen.queryByRole('button', { name: 'Which days are still empty?' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Attach a booking' })).toBeNull();
@@ -233,4 +241,20 @@ describe('AssistantSurface', () => {
fireEvent.dragEnter(input, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.queryByText('Drop it here and I will read the booking')).toBeNull();
});
+
+ it('FE-AI-SURF-009: a viewer of the journey hears in the book words that the chat changes nothing', async () => {
+ const key = assistantChatKey({ kind: 'journey', journeyId: TRIP });
+ useAssistantStore.setState({
+ status: STATUS,
+ send,
+ byTrip: { [key]: { ...chatOf(useAssistantStore.getState(), key), messages: [] } },
+ });
+ useJourneyStore.setState({ current: { id: TRIP, my_role: 'viewer' } as JourneyDetail });
+ render(
);
+
+ expect(
+ await screen.findByText('You can only view this book, so I can answer questions but not change anything.')
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/this trip/)).toBeNull();
+ });
});
diff --git a/client/src/components/Assistant/AssistantWelcome.tsx b/client/src/components/Assistant/AssistantWelcome.tsx
index b42bc4b61..617b5a59d 100644
--- a/client/src/components/Assistant/AssistantWelcome.tsx
+++ b/client/src/components/Assistant/AssistantWelcome.tsx
@@ -43,7 +43,9 @@ export default function AssistantWelcome({
title={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
/>
{!canEdit && (
-
{t('assistant.empty.readOnly')}
+
+ {book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
+
)}
{withSuggestions && offered.length > 0 &&
}
diff --git a/client/src/components/Assistant/AssistantWindow.test.tsx b/client/src/components/Assistant/AssistantWindow.test.tsx
index 33241e0e2..cb2717e04 100644
--- a/client/src/components/Assistant/AssistantWindow.test.tsx
+++ b/client/src/components/Assistant/AssistantWindow.test.tsx
@@ -1,10 +1,11 @@
/**
- * FE-AI-WIN-001 to FE-AI-WIN-011
+ * FE-AI-WIN-001 to FE-AI-WIN-012
*
* The desktop window around the chat: when it shows at all, the button it
* opens from, the trip picker outside a trip, closing that keeps the chat, and
* Escape, which stops an answer before it closes anything (and leaves the
- * header's menu to itself), and where the focus goes on the way. The geometry is
+ * header's menu to itself), and where the focus goes on the way; a trip's head
+ * offers the notes its turns read. The geometry is
* useDragResize's and has its own tests; here it is a fixed rect.
*/
import type { AiStatusResponse } from '@trek/shared';
@@ -180,6 +181,12 @@ describe('AssistantWindow', () => {
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
});
+ it('FE-AI-WIN-012: a trip head offers the trip notes, which every turn on the trip reads', () => {
+ seed({ open: true, minimized: false });
+ render(
);
+ expect(screen.getByRole('button', { name: 'Trip notes' })).toBeInTheDocument();
+ });
+
it('FE-AI-WIN-008: closing hands the focus to the round button, opening hands it back to the composer', async () => {
seed({ open: true, minimized: false });
render(
);
diff --git a/client/src/components/Assistant/cards/bookDesign/BookDesignSpreadStrip.tsx b/client/src/components/Assistant/cards/bookDesign/BookDesignSpreadStrip.tsx
index f7bbff2ea..84bd7f93c 100644
--- a/client/src/components/Assistant/cards/bookDesign/BookDesignSpreadStrip.tsx
+++ b/client/src/components/Assistant/cards/bookDesign/BookDesignSpreadStrip.tsx
@@ -4,6 +4,7 @@ import { type FocusEvent, useState } from 'react';
import { useTranslation } from '../../../../i18n';
import { SpreadThumb } from '../../../Studio/SpreadThumb';
+import { useStudioPageHint } from '../../../Studio/useStudioPageHint';
import { pageLabel } from '../book/bookCardModel';
import { ChangeMark } from '../parts/CardFacts';
import type { DesignPair } from './bookDesignModel';
@@ -55,12 +56,19 @@ interface SheetProps {
onShow: () => void;
}
-/** One page drawn small; pressing it shows the page. */
+/** One page drawn small; pressing it shows the page, pointing at it outlines it in the Studio's page rail. */
function Sheet({ spread, page, caption, onShow }: SheetProps) {
const { t } = useTranslation();
+ const hint = useStudioPageHint(spread.id);
const width = (SHEET_HEIGHT * (spread.role === 'inner' ? page.pageWidth * 2 : page.pageWidth)) / page.pageHeight;
return (
-
+
{caption && {caption} }
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 (
+
+
+ {t('journey.studio.assistant.toggle')}
+
+ );
+}
+
+/** 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 (
setActive(i)}
>
{
expect(filter?.(picture('/api/photos/7/thumbnail'))).toBe(true);
});
- it('FE-STUDIO-SNAP-005: an image too large for the server is not sent, a small sheet is not enlarged', async () => {
+ it('FE-STUDIO-SNAP-005: an image too large for the server is not sent, a small sheet is drawn to the long edge', async () => {
vi.mocked(domToBlob).mockResolvedValueOnce(new Blob([new Uint8Array(ASSISTANT_PAGE_IMAGE.maxBytes + 1)]));
expect(await snapshotSpread(sheet(), { width: 1600, height: 800 }, HIDDEN)).toBeNull();
vi.mocked(domToBlob).mockResolvedValueOnce(new Blob(['jpeg'], { type: 'image/jpeg' }));
const small = await snapshotSpread(sheet(), { width: 400, height: 300 }, HIDDEN);
- expect(small).toMatchObject({ width: 400, height: 300 });
- expect(blobCalls()[1]?.[1]).toMatchObject({ scale: 1 });
+ expect(small).toMatchObject({ width: 1280, height: 960 });
+ expect(blobCalls()[1]?.[1]).toMatchObject({ width: 400, height: 300, scale: 3.2 });
});
});
diff --git a/client/src/components/Studio/pageSnapshot.ts b/client/src/components/Studio/pageSnapshot.ts
index 9bdf91eeb..db77d02ee 100644
--- a/client/src/components/Studio/pageSnapshot.ts
+++ b/client/src/components/Studio/pageSnapshot.ts
@@ -113,10 +113,12 @@ async function picturesLoaded(root: Element, hidden: ReadonlySet): Promi
}
/**
- * The sheet under `root` as a JPEG, its long edge at most
- * `ASSISTANT_PAGE_IMAGE.longEdge` pixels. `size` is the sheet's own size in
- * CSS pixels (`sheetSizePx`). Null when the image comes out larger than the
- * server takes, so the turn goes on with the page's description.
+ * The sheet under `root` as a JPEG, its long edge at
+ * `ASSISTANT_PAGE_IMAGE.longEdge` pixels (journey-studio.md 3.2: a double
+ * spread 1280 x 640, a single page 1280 on its long edge). `size` is the
+ * sheet's own size in CSS pixels (`sheetSizePx`). Null when the image comes
+ * out larger than the server takes, so the turn goes on with the page's
+ * description.
*/
export async function snapshotSpread(
root: HTMLElement,
@@ -124,7 +126,8 @@ export async function snapshotSpread(
hiddenPhotoIds: ReadonlySet
): Promise {
await picturesLoaded(root, hiddenPhotoIds);
- const scale = Math.min(1, ASSISTANT_PAGE_IMAGE.longEdge / Math.max(size.width, size.height));
+ // A single page is drawn up to the long edge too: a larger scale renders the page sharper, it stretches no pixels.
+ const scale = ASSISTANT_PAGE_IMAGE.longEdge / Math.max(size.width, size.height);
const { domToBlob } = await import('modern-screenshot');
const image = await domToBlob(root, {
width: size.width,
diff --git a/client/src/components/Studio/useStudioAssistant.test.tsx b/client/src/components/Studio/useStudioAssistant.test.tsx
index d4f84046b..acbc78cc0 100644
--- a/client/src/components/Studio/useStudioAssistant.test.tsx
+++ b/client/src/components/Studio/useStudioAssistant.test.tsx
@@ -5,8 +5,8 @@
* hosts the journey's chat while the column shows; it draws the page a turn
* asks for off screen, with other members' photos as empty frames, and sends
* it at the version the turn named; a page of a design draft comes from the
- * draft; a preview runs on an unsaved copy; each question carries the page on
- * screen; `?page=` shows a page. The snapshot
+ * draft; a preview draws the page the server applied, nothing saved; each
+ * question carries the page on screen; `?page=` shows a page. The snapshot
* and the repo are faked: a real browser draws the page in the Playwright flows.
*/
import {
@@ -198,20 +198,25 @@ describe('useStudioAssistant', () => {
expect(background).toMatch(/#abcdef|171, 205, 239/i);
});
- it('FE-STUDIO-AST-004:a preview runs its ops on an unsaved copy, nothing is drawn when they fail', () => {
+ it('FE-STUDIO-AST-004: a preview draws the page the server applied, the stored book untouched', () => {
const doc = book();
const deadline = { signal: new AbortController().signal, release: vi.fn() };
+ const stored = doc.spreads[2];
+ if (!stored) throw new Error('no inner spread');
- const job = renderJobOf(doc, { ...REQUEST, ops: [{ op: 'background', color: '#123456' }] }, deadline);
+ const job = renderJobOf(doc, { ...REQUEST, spread: { ...stored, background: '#123456' } }, deadline);
expect(job?.spread.background).toBe('#123456');
+ expect(job?.page).toBe(doc.page);
expect(job?.folios.length).toBeGreaterThan(0);
expect([...(job?.hidden ?? [])]).toEqual([31]);
expect(doc.spreads[2]?.background).toBeNull();
+ // Without a proposal the page is drawn as the book holds it; a page the book lacks is not drawn.
+ expect(renderJobOf(doc, REQUEST, deadline)?.spread).toBe(stored);
expect(renderJobOf(doc, { ...REQUEST, spreadId: 'sp-99' }, deadline)).toBeNull();
- // An element the page does not hold refuses the whole edit.
- const missing: AssistantPageRender = { ...REQUEST, ops: [{ op: 'remove', elementIds: ['nope'], park: true }] };
- expect(renderJobOf(doc, missing, deadline)).toBeNull();
+ // A page sent for another spread is never drawn in this one's place.
+ const other: AssistantPageRender = { ...REQUEST, spread: { ...stored, id: 'sp-4', background: '#654321' } };
+ expect(renderJobOf(doc, other, deadline)?.spread).toBe(stored);
});
it('FE-STUDIO-AST-005: each question carries the page on screen and the elements selected on it', () => {
diff --git a/client/src/components/Studio/useStudioAssistant.ts b/client/src/components/Studio/useStudioAssistant.ts
index dc245630b..71eaaab55 100644
--- a/client/src/components/Studio/useStudioAssistant.ts
+++ b/client/src/components/Studio/useStudioAssistant.ts
@@ -1,6 +1,5 @@
import {
ASSISTANT_PAGE_IMAGE,
- applyBookOps,
bookElementIdSchema,
bookSpreadIdSchema,
type AssistantPageRender,
@@ -27,7 +26,8 @@ import { sheetSizePx, snapshotSpread } from './pageSnapshot';
* - Each question carries the page on screen and the elements selected on it.
* - A page a turn asks to look at is drawn here, by the editor's own
* SpreadView off screen, and uploaded as an image (3.1): a page of the
- * book, or of a design draft the assistant has not applied yet.
+ * book, of a design draft the assistant has not applied yet, or a
+ * proposal's page as the server applied it.
* - `?page=`, which a page card opens, shows that page.
*/
@@ -83,29 +83,27 @@ function withDeadline(signal: AbortSignal): { signal: AbortSignal; release: () =
}
/**
- * The page a request asks for, from the book at the version it names: with
- * the request's ops applied to an unsaved copy (a preview). Null when the
- * page is gone or the ops do not apply, so nothing is drawn.
+ * The page a request asks for, from the book at the version it names. A
+ * preview carries the proposal's page as the server applied it, with the
+ * journey's photos, words and figures this tab does not hold: that page is
+ * drawn in place of the stored one, nothing saved. A proposal changes only
+ * its own page, so the page setup and the folios stay the book's. Null when
+ * the page is gone, so nothing is drawn.
*/
export function renderJobOf(
doc: BookDocument,
request: AssistantPageRender,
deadline: { signal: AbortSignal; release: () => void }
): StudioRenderJob | null {
- let next = doc;
- if (request.ops) {
- const made = applyBookOps(doc, { spreadId: request.spreadId, ops: request.ops });
- if (made.ok === false) return null;
- next = made.doc;
- }
- const index = next.spreads.findIndex((s) => s.id === request.spreadId);
- const spread = next.spreads[index];
- if (!spread) return null;
+ const index = doc.spreads.findIndex((s) => s.id === request.spreadId);
+ const stored = doc.spreads[index];
+ if (!stored) return null;
+ const spread = request.spread?.id === stored.id ? request.spread : stored;
return {
request,
spread,
- page: next.page,
- folios: foliosOf(next.spreads, index, next.page.pageNumbers.startAt),
+ page: doc.page,
+ folios: foliosOf(doc.spreads, index, doc.page.pageNumbers.startAt),
hidden: new Set(request.hiddenPhotoIds),
...deadline,
};
diff --git a/client/src/components/Studio/useStudioPageHint.test.tsx b/client/src/components/Studio/useStudioPageHint.test.tsx
new file mode 100644
index 000000000..38809a6a4
--- /dev/null
+++ b/client/src/components/Studio/useStudioPageHint.test.tsx
@@ -0,0 +1,139 @@
+/**
+ * FE-STUDIO-HINT-001 to FE-STUDIO-HINT-003
+ *
+ * Pointing at a page drawn in a chat card outlines the same page in the
+ * Studio's page rail (journey-studio.md 4.2): by pointer or by focus, gone
+ * again on leaving, on blur and when the card goes away while it points; a
+ * page the open book does not hold outlines nothing, and opening a book
+ * clears it. A book change's card points this way from its thumbnails.
+ */
+import { blankBook, type BookPageCard } from '@trek/shared';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
+import { useStudioStore } from '../../store/studioStore';
+import type { ChangeItem } from '../Assistant/assistantParts';
+import { AssistantViewContext } from '../Assistant/assistantViewContext';
+import BookPageThumbs from '../Assistant/cards/book/BookPageThumbs';
+import { type JourneySource, StudioSidebar } from './StudioSidebar';
+import type { StudioUploader } from './studioUpload';
+import { useStudioPageHint } from './useStudioPageHint';
+
+const STUDIO = useStudioStore.getState();
+
+const SOURCE: JourneySource = { entries: [], photos: [], photoEntries: {} };
+const NO_UPLOAD: StudioUploader = async () => ({ photoIds: [], failed: 0, skippedVideos: 0 });
+
+/** A blank book with readable ids: sp-1 the cover, sp-2 the first page, sp-3 its double page, sp-4 and sp-5. */
+function book() {
+ let seq = 0;
+ return blankBook({ title: 'Lisbon', newId: (prefix) => `${prefix}-${++seq}` });
+}
+
+/** The page rail of the open book. */
+function renderRail() {
+ const doc = useStudioStore.getState().doc;
+ if (!doc) throw new Error('no book open');
+ return render(
+ k}
+ locale="en-US"
+ canEdit
+ onUpload={NO_UPLOAD}
+ onToggleStop={async () => true}
+ />
+ );
+}
+
+/** The rail's thumbnails, in the book's order, by whether each is outlined. */
+function outlined(): boolean[] {
+ return [...document.querySelectorAll('.st-thumb')].map((thumb) => thumb.classList.contains('is-hinted'));
+}
+
+/** A page drawn in a card, as the card thumbnails draw one: a button that points at it. */
+function CardPage({ spreadId }: { spreadId: string }) {
+ const hint = useStudioPageHint(spreadId);
+ return (
+
+ {`card page ${spreadId}`}
+
+ );
+}
+
+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': '允许查看页面',