mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
fix(studio): the assistant column draws the page the server applied, and reads the user's role
A page preview draws the spread the server applied with the journey's photos, texts and figures instead of applying the operations again without them. The Studio's welcome, privacy banner and header fit a journey chat: a viewer is told they can only look, the banner speaks of the book, and the trip notes button stays in the trip chat. Escape closes the header menu before it stops a reply, a single page renders at the full long edge, the status says a model reads pages only while its provider has not refused an image, and pointing at a page in a design card outlines it in the Studio.
This commit is contained in:
@@ -6,8 +6,9 @@ import { checkPageSnapshot } from './support/pageSnapshotFlow';
|
||||
// has no WebKit project; the engine comes from this file-level device, as in
|
||||
// e2e/urlbar-scroll-1809.spec.ts. Safari is why the rasteriser is
|
||||
// modern-screenshot: it decodes images in a foreignObject late, and a page
|
||||
// drawn too early comes out blank.
|
||||
test.use({ ...devices['Desktop Safari'] });
|
||||
// drawn too early comes out blank. page.route does not see a request the
|
||||
// service worker answers, so it is blocked.
|
||||
test.use({ ...devices['Desktop Safari'], serviceWorkers: 'block' });
|
||||
|
||||
test('Studio page snapshot: WebKit draws a seeded spread to a JPEG', async ({ page }) => {
|
||||
await checkPageSnapshot(page);
|
||||
|
||||
@@ -5,6 +5,9 @@ import { checkPageSnapshot } from './support/pageSnapshotFlow';
|
||||
// The Studio's page image for the assistant, drawn in Chromium. The same flow
|
||||
// runs in WebKit from page-snapshot-webkit.spec.ts, the engine with the most
|
||||
// to get wrong about drawing a page through an SVG foreignObject.
|
||||
// page.route does not see a request the service worker answers, so it is blocked.
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
test('Studio page snapshot: Chromium draws a seeded spread to a JPEG', async ({ page }) => {
|
||||
await checkPageSnapshot(page);
|
||||
});
|
||||
|
||||
@@ -2,22 +2,41 @@ import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import type { SeededSnapshot } from './pageSnapshotHarness';
|
||||
|
||||
/** The map tile from another server that the harness puts on the sheet. */
|
||||
const TILE = 'https://tile.openstreetmap.org/5/16/10.png?e2e=page-snapshot';
|
||||
|
||||
/**
|
||||
* The page snapshot the Studio sends the assistant, taken in a real browser
|
||||
* (journey-studio.md 3.1): the harness module is loaded into the running app
|
||||
* through the dev server, draws a seeded spread with the editor's SpreadView
|
||||
* and rasterises it with modern-screenshot. The image has to be a non-empty
|
||||
* JPEG at the size the Studio promises the server, and the map tile from
|
||||
* another server on the sheet must never have been fetched for it.
|
||||
* another server on the sheet must never have been fetched for it. Every
|
||||
* request to OpenStreetMap's tile servers is answered here, which keeps the
|
||||
* run off the network, but only the harness's own tile counts: the sheet's
|
||||
* <img> asks for it as an image, anything else would be the rasteriser
|
||||
* reaching for it. Its query string sets it apart from the tiles the app
|
||||
* itself may prefetch from the same servers. The specs block the service
|
||||
* worker, whose tile cache would answer before the route sees a request.
|
||||
*/
|
||||
export async function checkPageSnapshot(page: Page): Promise<void> {
|
||||
const tileFetches: string[] = [];
|
||||
await page.route(/^https:\/\/(?:[a-c]\.)?tile\.openstreetmap\.org\//, (route) => {
|
||||
const request = route.request();
|
||||
const kind = request.resourceType();
|
||||
if (request.url().startsWith(TILE) && kind !== 'image') tileFetches.push(kind);
|
||||
return route.fulfill({ status: 204, headers: { 'access-control-allow-origin': '*' } });
|
||||
});
|
||||
await page.goto('/dashboard');
|
||||
const shot = await page.evaluate(async (harness: string): Promise<SeededSnapshot | null> => {
|
||||
const mod = (await import(/* @vite-ignore */ harness)) as {
|
||||
snapshotSeededSpread: () => Promise<SeededSnapshot | null>;
|
||||
};
|
||||
return mod.snapshotSeededSpread();
|
||||
}, '/e2e/support/pageSnapshotHarness.ts');
|
||||
const shot = await page.evaluate(
|
||||
async ({ harness, tile }: { harness: string; tile: string }): Promise<SeededSnapshot | null> => {
|
||||
const mod = (await import(/* @vite-ignore */ harness)) as {
|
||||
snapshotSeededSpread: (tileUrl: string) => Promise<SeededSnapshot | null>;
|
||||
};
|
||||
return mod.snapshotSeededSpread(tile);
|
||||
},
|
||||
{ harness: '/e2e/support/pageSnapshotHarness.ts', tile: TILE }
|
||||
);
|
||||
|
||||
expect(shot, 'the spread was drawn and came out under the size limit').not.toBeNull();
|
||||
if (!shot) return;
|
||||
@@ -30,5 +49,5 @@ export async function checkPageSnapshot(page: Page): Promise<void> {
|
||||
expect(shot.decodedWidth).toBe(1280);
|
||||
expect(shot.decodedHeight).toBe(640);
|
||||
expect(shot.drawnPhotoIds).toEqual([]);
|
||||
expect(shot.tileRequested, 'the tile from another server is left out before anything is fetched').toBe(false);
|
||||
expect(tileFetches, 'the tile from another server is left out before anything is fetched').toEqual([]);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import { sheetSizePx, snapshotSpread } from '../../src/components/Studio/pageSna
|
||||
* server serves it as a module: draws a seeded spread with the editor's own
|
||||
* SpreadView and turns it into the JPEG the Studio sends the assistant, so a
|
||||
* real Chromium and a real WebKit prove the rasteriser works end to end. A map
|
||||
* tile from another server sits on the sheet and must be left out.
|
||||
* tile from another server sits on the sheet and must be left out; the flow
|
||||
* names the tile and watches its server.
|
||||
*/
|
||||
|
||||
export interface SeededSnapshot {
|
||||
@@ -25,12 +26,8 @@ export interface SeededSnapshot {
|
||||
decodedWidth: number;
|
||||
decodedHeight: number;
|
||||
drawnPhotoIds: number[];
|
||||
/** Whether the snapshot reached the map tile's server at all. */
|
||||
tileRequested: boolean;
|
||||
}
|
||||
|
||||
const TILE = 'https://tile.openstreetmap.org/5/16/10.png';
|
||||
|
||||
function seededSpread(): BookSpread {
|
||||
return {
|
||||
id: 'sp-seeded',
|
||||
@@ -50,7 +47,7 @@ function seededSpread(): BookSpread {
|
||||
};
|
||||
}
|
||||
|
||||
export async function snapshotSeededSpread(): Promise<SeededSnapshot | null> {
|
||||
export async function snapshotSeededSpread(tileUrl: string): Promise<SeededSnapshot | null> {
|
||||
const page = bookPageSetupForPreset('square-210');
|
||||
const spread = seededSpread();
|
||||
const size = sheetSizePx(spread, page);
|
||||
@@ -64,15 +61,13 @@ export async function snapshotSeededSpread(): Promise<SeededSnapshot | null> {
|
||||
const root = createRoot(sheet);
|
||||
flushSync(() => root.render(createElement(SpreadView, { spread, page, print: true })));
|
||||
const tile = document.createElement('img');
|
||||
tile.src = TILE;
|
||||
tile.src = tileUrl;
|
||||
sheet.firstElementChild?.appendChild(tile);
|
||||
const before = performance.getEntriesByType('resource').length;
|
||||
try {
|
||||
const shot = await snapshotSpread(sheet, size, new Set());
|
||||
if (!shot) return null;
|
||||
const head = [...new Uint8Array(await shot.image.slice(0, 3).arrayBuffer())];
|
||||
const bitmap = await createImageBitmap(shot.image);
|
||||
const fetched = performance.getEntriesByType('resource').slice(before);
|
||||
return {
|
||||
type: shot.image.type,
|
||||
bytes: shot.image.size,
|
||||
@@ -82,7 +77,6 @@ export async function snapshotSeededSpread(): Promise<SeededSnapshot | null> {
|
||||
decodedWidth: bitmap.width,
|
||||
decodedHeight: bitmap.height,
|
||||
drawnPhotoIds: shot.drawnPhotoIds,
|
||||
tileRequested: fetched.some((entry) => entry.name === TILE && entry.initiatorType !== 'img'),
|
||||
};
|
||||
} finally {
|
||||
root.unmount();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { assistantScopeOf } from '../../store/assistantChatKey';
|
||||
import { PopoverItem } from '../Packing/PackingPopover';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu';
|
||||
@@ -36,12 +37,14 @@ interface AssistantHeaderProps {
|
||||
* grabbed to move it. On the end side the recent chats (a new chat starts at
|
||||
* the top of that list), more and close, as round raised buttons. Closing
|
||||
* keeps the conversation, and an answer being written goes on. The model is not picked here;
|
||||
* that lives in the settings.
|
||||
* that lives in the settings. Notes are a trip's: a journey's chat in TREK Studio shows no notes button.
|
||||
*/
|
||||
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const memory = useTripMemory(tripId);
|
||||
// Trip notes belong to a trip: a journey's turns never read them (assistant-scope.ts turnOf).
|
||||
const tripChat = assistantScopeOf(tripId).kind === 'trip';
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const menu = useContextMenu();
|
||||
@@ -123,22 +126,24 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMemoryOpen(true)}
|
||||
aria-label={t('assistant.memory.title')}
|
||||
className={`relative ${ICON_BUTTON}`}
|
||||
>
|
||||
<NotebookPen size={13} aria-hidden="true" />
|
||||
{memory.has && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -end-0.5 -top-0.5 h-2 w-2 rounded-full bg-accent ring-2 ring-[color:var(--bg-elevated)]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{tripChat && (
|
||||
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMemoryOpen(true)}
|
||||
aria-label={t('assistant.memory.title')}
|
||||
className={`relative ${ICON_BUTTON}`}
|
||||
>
|
||||
<NotebookPen size={13} aria-hidden="true" />
|
||||
{memory.has && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -end-0.5 -top-0.5 h-2 w-2 rounded-full bg-accent ring-2 ring-[color:var(--bg-elevated)]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('assistant.history.open')}>
|
||||
<button
|
||||
ref={historyTrigger}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { useAssistantScope } from './assistantViewContext';
|
||||
import { providerLabel } from './providerCatalog';
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,8 @@ import { providerLabel } from './providerCatalog';
|
||||
* to this while connecting; on the admin's connection the chat says it here,
|
||||
* above the composer, until the user has read it. A new provider set up by
|
||||
* the admin asks again, because the answer is stored per provider and host.
|
||||
* In TREK Studio the chat is about a journey and its book, so the banner
|
||||
* speaks of those; a picture of a page has the Studio's own notice.
|
||||
*/
|
||||
export default function AssistantPrivacyBanner() {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,6 +22,8 @@ export default function AssistantPrivacyBanner() {
|
||||
const acked = useAssistantStore((s) => s.status?.privacyAcked ?? true);
|
||||
const ackPrivacy = useAssistantStore((s) => s.ackPrivacy);
|
||||
const [details, setDetails] = useState(false);
|
||||
// The Studio's chat reads a journey, never a trip, and may show pages once the user allows it.
|
||||
const book = useAssistantScope().kind === 'journey';
|
||||
|
||||
if (!effective || effective.source !== 'instance' || acked) return null;
|
||||
const provider = providerLabel(effective.kind, effective.host);
|
||||
@@ -35,12 +40,14 @@ export default function AssistantPrivacyBanner() {
|
||||
<div className="trek-assistant-enter rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 text-caption text-content-secondary">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck size={14} aria-hidden="true" className="mt-0.5 flex-shrink-0 text-content-muted" />
|
||||
<p className="m-0 min-w-0 flex-1">{t('assistant.privacy.banner', { provider })}</p>
|
||||
<p className="m-0 min-w-0 flex-1">
|
||||
{book ? t('assistant.privacy.bannerBook', { provider }) : t('assistant.privacy.banner', { provider })}
|
||||
</p>
|
||||
</div>
|
||||
{details && (
|
||||
<ul className="m-0 mt-2 list-disc space-y-1 ps-9">
|
||||
<li>{t('assistant.privacy.shared')}</li>
|
||||
<li>{t('assistant.privacy.notShared')}</li>
|
||||
<li>{book ? t('assistant.privacy.sharedBook') : t('assistant.privacy.shared')}</li>
|
||||
<li>{book ? t('assistant.privacy.notSharedBook') : t('assistant.privacy.notShared')}</li>
|
||||
<li>{t('assistant.privacy.provider', { provider })}</li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -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(<AssistantSurface tripId={key} tripTitle="Lisbon" variant="window" />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,9 @@ export default function AssistantWelcome({
|
||||
title={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
|
||||
/>
|
||||
{!canEdit && (
|
||||
<p className="m-0 max-w-xs text-center text-caption text-content-muted">{t('assistant.empty.readOnly')}</p>
|
||||
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
|
||||
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
|
||||
</p>
|
||||
)}
|
||||
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
|
||||
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
|
||||
|
||||
@@ -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(<AssistantWindow trip={TRIP} />);
|
||||
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(<AssistantWindow trip={TRIP} />);
|
||||
|
||||
@@ -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 (
|
||||
<button type="button" onClick={onShow} aria-label={t('assistant.card.book.showPage')} className={SHEET_BUTTON}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShow}
|
||||
{...hint}
|
||||
aria-label={t('assistant.card.book.showPage')}
|
||||
className={SHEET_BUTTON}
|
||||
>
|
||||
<SpreadThumb spread={spread} page={page} width={width} className={SHEET} />
|
||||
{caption && <span className={SHEET_CAPTION}>{caption}</span>}
|
||||
</button>
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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> = {}): 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(<StudioAssistantPanel assistant={panel} title="Lisbon" />);
|
||||
|
||||
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(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
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(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
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(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
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(<StudioAssistantToggle assistant={assistant({ available: false })} />);
|
||||
expect(screen.queryByRole('button', { name: 'Assistant' })).toBeNull();
|
||||
unmount();
|
||||
|
||||
const closed = assistant({ open: false });
|
||||
const second = render(<StudioAssistantToggle assistant={closed} />);
|
||||
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(<StudioAssistantToggle assistant={assistant()} />);
|
||||
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(<StudioAssistantColumn assistant={assistant({ open: false })} title="Lisbon" />);
|
||||
expect(screen.queryByRole('complementary', { name: 'Book assistant' })).toBeNull();
|
||||
unmount();
|
||||
|
||||
render(<StudioAssistantColumn assistant={assistant()} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLElement> = { style: { cursor: 'default' } };
|
||||
/**
|
||||
* Images of a page leave only once the user agreed, per connection. Until
|
||||
* then the Studio's assistant reads the pages as text. "Not now" hides the
|
||||
* notice for this visit; the next one asks again.
|
||||
* 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<HTMLElement>) => {
|
||||
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:
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`st-tool ${assistant.open ? 'is-on' : ''}`}
|
||||
onClick={assistant.toggle}
|
||||
aria-pressed={assistant.open}
|
||||
title={t('journey.studio.assistant.toggleHint')}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span className="st-tool-label">{t('journey.studio.assistant.toggle')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <StudioAssistantPanel assistant={assistant} title={title || t('journey.title')} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="st-thumb-row" key={sp.id}>
|
||||
<button type="button"
|
||||
className={`st-thumb ${i === active ? 'is-active' : ''}`}
|
||||
className={`st-thumb ${i === active ? 'is-active' : ''} ${sp.id === hinted ? 'is-hinted' : ''}`}
|
||||
onClick={() => setActive(i)}
|
||||
>
|
||||
<SpreadThumb
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* The page image the Studio sends the assistant (journey-studio.md 3.1, 3.4):
|
||||
* a sheet's size in CSS pixels, the filter that leaves out pictures from
|
||||
* other servers and hidden photos before anything is rasterised, the photos
|
||||
* the upload declares read off what is kept, and the JPEG at most 1280 pixels
|
||||
* the upload declares read off what is kept, and the JPEG at 1280 pixels
|
||||
* on its long edge. modern-screenshot is faked; a real browser draws the page
|
||||
* in the Playwright flows.
|
||||
*/
|
||||
@@ -102,13 +102,13 @@ describe('pageSnapshot', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,10 +113,12 @@ async function picturesLoaded(root: Element, hidden: ReadonlySet<number>): 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<number>
|
||||
): Promise<PageSnapshot | null> {
|
||||
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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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=<spread id>`, 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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
<StudioSidebar
|
||||
page={doc.page}
|
||||
pxPerMm={96 / 25.4}
|
||||
bookView={false}
|
||||
source={SOURCE}
|
||||
stats={null}
|
||||
path={[]}
|
||||
t={(k: string) => 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 (
|
||||
<button type="button" {...hint}>
|
||||
{`card page ${spreadId}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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(<CardPage spreadId="sp-3" />);
|
||||
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(<CardPage spreadId="sp-new" />);
|
||||
fireEvent.pointerEnter(screen.getByRole('button', { name: 'card page sp-new' }));
|
||||
expect(useStudioStore.getState().hintSpread).toBeNull();
|
||||
|
||||
const card = render(<CardPage spreadId="sp-4" />);
|
||||
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(<CardPage spreadId="sp-2" />);
|
||||
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(
|
||||
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window' }}>
|
||||
<BookPageThumbs card={card} item={item} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(
|
||||
<div onKeyDown={parentKeys}>
|
||||
<ContextMenu menu={{ x: 0, y: 0, items: [{ label: 'Open' }] }} onClose={onClose} />
|
||||
</div>,
|
||||
)
|
||||
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(<ContextMenu menu={{ x: 0, y: 0, items: [{ label: 'Off', disabled: true }, { label: 'Open' }, { label: 'Rename' }] }} onClose={() => {}} />)
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<StudioState>((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<StudioState>((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] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
/** Whether the assistant exists here at all: the addon is on and the install is not centrally administered. */
|
||||
async enabled(): Promise<boolean> {
|
||||
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<ResolvedAiConnection, 'kind' | 'model'>): 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<ResolvedAiConnection, 'kind' | 'model'>): void {
|
||||
this.textOnly.add(`${kind}:${model}`);
|
||||
}
|
||||
|
||||
// ── The instance's connection (admin) ──────────────────────────────────────
|
||||
|
||||
async readSettings(): Promise<AiAdminSettings> {
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
/** 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<TurnVision> {
|
||||
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<PageImage | null> {
|
||||
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`);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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': 'خطط لي الأيام الأولى',
|
||||
|
||||
@@ -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': 'اعرض الصفحات',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
'Μπορείτε μόνο να δείτε αυτό το ταξίδι, οπότε απαντώ σε ερωτήσεις αλλά δεν αλλάζω τίποτα.',
|
||||
|
||||
@@ -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': 'Εμφάνιση σελίδων',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '最初の数日を計画して',
|
||||
|
||||
@@ -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': 'ページを見せる',
|
||||
|
||||
@@ -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': '처음 며칠 일정을 짜 줘',
|
||||
|
||||
@@ -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': '페이지 보여주기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
'Эту поездку вы можете только просматривать, поэтому я отвечу на вопросы, но ничего не изменю.',
|
||||
|
||||
@@ -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': 'Показывать страницы',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ช่วยวางแผนวันแรก ๆ ให้หน่อย',
|
||||
|
||||
@@ -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': 'แสดงหน้าให้ดู',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
'Ви можете лише переглядати цю подорож, тож я відповідаю на запитання, але нічого не змінюю.',
|
||||
|
||||
@@ -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': 'Показувати сторінки',
|
||||
|
||||
@@ -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ì.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '幫我規劃前幾天',
|
||||
|
||||
@@ -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': '允許查看頁面',
|
||||
|
||||
@@ -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': '帮我规划前几天',
|
||||
|
||||
@@ -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': '允许查看页面',
|
||||
|
||||
Reference in New Issue
Block a user