feat(assistant): photo changes of a journal get their own card and ways back

The four new photo link tools join the journal's tools: the journey chat
offers them to editors, the trip chat loads them with the journey group and
the home chat keeps none. A journeyPhotos card shows the photos as
thumbnails with the entries they leave and reach, the first one marked as
the cover in a new order, and a caption old to new; a library add shows the
photos once they landed. A linked photo comes off again, an unlinked one
goes back, moved photos go back and a caption is written back, each while
nothing changed since. A new order of an entry's photos and a library add
have no way back yet. The journal prompt names the photo tools.
This commit is contained in:
Maurice
2026-10-24 22:07:46 +02:00
parent da43a81d26
commit 0050072b76
43 changed files with 1257 additions and 10 deletions
@@ -33,6 +33,7 @@ export interface JourneyOffer {
/** The controllers of the journal: the journey and its entries, its map and tracks, its recorded days and imports. */
const JOURNAL_CONTROLLERS: ReadonlySet<string> = new Set([
'JourneyMcp',
'JourneyPhotoLinksMcp',
'JourneyMapMcp',
'JourneyTracksMcp',
'JourneyRecordingsMcp',
@@ -227,6 +227,7 @@ const TRIP_OR_JOURNEY_ARGS: ReadonlySet<string> = new Set([
*/
const JOURNEY_WRITE_CONTROLLERS: ReadonlySet<string> = new Set([
'JourneyMcp',
'JourneyPhotoLinksMcp',
'JourneyMapMcp',
'JourneyTracksMcp',
'JourneyRecordingsMcp',
@@ -334,6 +335,7 @@ const CORE_TOOL_GROUPS: ToolGroups = {
'location history and the recorded days.',
controllers: [
'JourneyMcp',
'JourneyPhotoLinksMcp',
'JourneyMapMcp',
'JourneyTracksMcp',
'JourneyBookMcp',
@@ -110,7 +110,7 @@ How you work:
- A new entry needs its day (entry_date). When it is about a place, first find the place with search_place by its name and town, then pass its name as location_name and its coordinates as location_lat and location_lng: an entry without coordinates never appears on the journey's map.
- A new entry stays a draft (is_draft true) unless the user asks to publish it.
- Write titles, stories, moods, weather, tags and pros and cons from what the user told you. Make texts up only when the user asks you to, and then say in one sentence that you did.
- An entry's photos and their ids come from list_journey_entries or get_journey_entry; photos from the user's photo library are found with search_provider_photos or the album tools and added with add_journey_provider_photos.
- An entry's photos and their ids come from list_journey_entries or get_journey_entry, the whole gallery's from get_journey. Put a gallery photo on an entry with link_journey_photo, take one off with unlink_journey_photo (it stays in the gallery), move photos between entries with move_journey_photos, order an entry's photos with reorder_journey_entry_photos (the first is its cover) and caption a photo with set_journey_photo_caption. Photos from the user's photo library are found with search_provider_photos or the album tools and added with add_journey_provider_photos.
- Only the owner changes the journal itself (its name, subtitle, status and display settings) and which trips it holds. Deleting the journal, sharing it and who may see it are not done here: say so in one sentence and point to the journal's settings.
- The photo book of the journey is laid out in its Studio, which has an assistant of its own; its tools are not offered here.
- If the journal card says the user may not change the journal, propose no changes; explain what they could ask the journal's owner for instead.
@@ -31,6 +31,20 @@ import {
type JournalOrderLoaded,
type StayLoaded,
} from './journey-order';
import {
describeJournalPhotos,
journalPhotosApplied,
journalPhotosCard,
loadJournalPhotos,
type JournalPhotosLoaded,
type JournalPhotosTool,
} from './journey-photos';
import {
MOVE_PHOTOS_BACK,
RELINK_UNLINKED_PHOTO,
RESTORE_PHOTO_CAPTION,
UNLINK_LINKED_PHOTO,
} from './journey-photos-undo';
import { DELETE_CREATED_ENTRY, RESTORE_CHANGED_ENTRY, RESTORE_ENTRY_ORDER } from './journey-undo';
import type { AssistantChangeVerbKey, JourneyEntriesCard } from '@trek/shared';
@@ -124,6 +138,29 @@ const stayCards: ToolCards<StayLoaded> = {
},
};
/** The entry a photo change touched, else the journal (a caption). */
function photoIds(args: ToolArgs, result: unknown): number[] {
const id = argId(args, 'toEntryId') ?? argId(args, 'entryId') ?? argId(recordOf(result), 'toEntryId');
return id === null ? [] : [id];
}
/** A change of a journal's photos: its verb, its words, its lookups, its card while held and once applied. */
function journalPhotos(
tool: JournalPhotosTool,
verbKey: AssistantChangeVerbKey,
undo?: NonNullable<ToolCards['undo']>,
): ToolCards<JournalPhotosLoaded> {
return {
write: { verbKey, entity: tool === 'set_journey_photo_caption' ? 'journey' : 'journey_entry' },
load: (args, read) => loadJournalPhotos(args, read),
describe: (ctx, args, loaded) => describeJournalPhotos(tool, ctx, args, loaded),
change: (_ctx, args, loaded) => journalPhotosCard(tool, args, loaded),
applied: ({ result, preview }) => journalPhotosApplied(tool, result, preview),
appliedIds: tool === 'set_journey_photo_caption' ? journalIds : photoIds,
...(undo ? { undo } : {}),
};
}
export const JOURNEY_CARDS: DomainCards = {
id: 'journey',
tools: {
@@ -156,6 +193,12 @@ export const JOURNEY_CARDS: DomainCards = {
...journalWrite('delete_journey_entry'),
},
reorder_journey_entries: reorderCards,
link_journey_photo: journalPhotos('link_journey_photo', 'linkJournalPhoto', UNLINK_LINKED_PHOTO),
unlink_journey_photo: journalPhotos('unlink_journey_photo', 'unlinkJournalPhoto', RELINK_UNLINKED_PHOTO),
move_journey_photos: journalPhotos('move_journey_photos', 'moveJournalPhotos', MOVE_PHOTOS_BACK),
reorder_journey_entry_photos: journalPhotos('reorder_journey_entry_photos', 'orderJournalPhotos'),
set_journey_photo_caption: journalPhotos('set_journey_photo_caption', 'captionJournalPhoto', RESTORE_PHOTO_CAPTION),
add_journey_provider_photos: journalPhotos('add_journey_provider_photos', 'addJournalPhotos'),
accept_dawarich_suggestion_as_journal_entry: stayCards,
create_journey: journalHeader('create_journey', 'createJournal', DELETE_CREATED_JOURNEY),
update_journey: journalHeader('update_journey', 'updateJournalSettings', RESTORE_JOURNEY_SETTINGS),
@@ -0,0 +1,114 @@
import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
import { argId, idIn, readKey, recordOf } from '../card-values';
import { rowsOf } from '../undo-snapshot';
/**
* The ways back from a change of a journal's photos, each checked against
* the entry as `get_journey_entry` reads it now:
* - a photo put on an entry comes off again while it is still there;
* - a photo taken off goes back while it is still off;
* - moved photos go back while the target holds them all and, unless they
* were kept, the source none of them;
* - a caption is written back while the photo still carries the new one.
*/
type Undo = NonNullable<ToolCards['undo']>;
function entryRead(entryId: number | null) {
return { tool: 'get_journey_entry', args: { entryId } };
}
/** The journey photo ids an entry holds now, as the snapshot read it; null when the read failed. */
function heldBy(snapshot: UndoSnapshot, entryId: number | null): Set<number> | null {
const read = entryRead(entryId);
const entry = recordOf(recordOf(snapshot.reads.get(readKey(read.tool, read.args))).entry);
if (entry.id !== entryId) return null;
return new Set(rowsOf(entry.photos).map((photo) => photo.id as number));
}
function linkArgs(args: ToolArgs): { entryId: number; journey_photo_id: number } | null {
const entryId = argId(args, 'entryId');
const photoId = argId(args, 'journey_photo_id');
return entryId === null || photoId === null ? null : { entryId, journey_photo_id: photoId };
}
export const UNLINK_LINKED_PHOTO: Undo = {
tool: 'unlink_journey_photo',
args: (args, _result, preview) => (preview?.undoable === false ? null : linkArgs(args)),
reads: (args) => [entryRead(argId(args, 'entryId'))],
unchanged: (_result, snapshot, call) => {
const link = linkArgs(call?.args ?? {});
return !!link && (heldBy(snapshot, link.entryId)?.has(link.journey_photo_id) ?? false);
},
};
export const RELINK_UNLINKED_PHOTO: Undo = {
tool: 'link_journey_photo',
args: (args, result) => (recordOf(result).success === true ? linkArgs(args) : null),
reads: (args) => [entryRead(argId(args, 'entryId'))],
unchanged: (_result, snapshot, call) => {
const link = linkArgs(call?.args ?? {});
const held = link ? heldBy(snapshot, link.entryId) : null;
return !!link && !!held && !held.has(link.journey_photo_id);
},
};
/** What a move answered it moved, between which entries. */
function movedBy(result: unknown) {
const answer = recordOf(result);
const moved = Array.isArray(answer.moved) ? answer.moved.filter((id): id is number => Number.isInteger(id)) : [];
return { moved, from: argId(answer, 'fromEntryId'), to: argId(answer, 'toEntryId'), kept: answer.kept === true };
}
export const MOVE_PHOTOS_BACK: Undo = {
tool: 'move_journey_photos',
args: (_args, result, preview) => {
const { moved, from, to } = movedBy(result);
if (preview?.undoable === false || moved.length === 0 || from === null || to === null) return null;
// Back to the source and off the target; a kept photo is on the source already, so only the target loses it.
return { fromEntryId: to, toEntryId: from, photoIds: moved };
},
reads: (_args, result) => {
const { from, to } = movedBy(result);
return [entryRead(to), entryRead(from)];
},
unchanged: (result, snapshot) => {
const { moved, from, to, kept } = movedBy(result);
const onTarget = heldBy(snapshot, to);
const onSource = heldBy(snapshot, from);
if (!onTarget || !onSource || moved.length === 0) return false;
return moved.every((id) => onTarget.has(id) && (kept || !onSource.has(id)));
},
};
/** The caption a caption change wrote, and the one it replaced. */
function captionsOf(result: unknown) {
const answer = recordOf(result);
return { photo: recordOf(answer.photo), previous: recordOf(answer.previous) };
}
export const RESTORE_PHOTO_CAPTION: Undo = {
tool: 'set_journey_photo_caption',
args: (args, result) => {
const { photo, previous } = captionsOf(result);
const photoId = idIn(result, 'photo');
if (photoId === null || !('caption' in previous)) return null;
const journeyId = argId(args, 'journeyId') ?? argId(photo, 'journey_id');
return {
...(journeyId === null ? {} : { journeyId }),
journey_photo_id: photoId,
caption: previous.caption ?? null,
};
},
reads: (args) => [
{ tool: 'get_journey', args: argId(args, 'journeyId') === null ? {} : { journeyId: args.journeyId } },
],
unchanged: (result, snapshot, call) => {
const args = call?.args ?? {};
const read = { tool: 'get_journey', args: argId(args, 'journeyId') === null ? {} : { journeyId: args.journeyId } };
const journey = recordOf(recordOf(snapshot.reads.get(readKey(read.tool, read.args))).journey);
const { photo } = captionsOf(result);
const current = rowsOf(journey.gallery).find((row) => row.id === photo.id);
return !!current && (current.caption ?? null) === (photo.caption ?? null);
},
};
@@ -0,0 +1,224 @@
import type { CardReader, Described, PreviewContext, ToolArgs } from '../card-types';
import { argId, clipped, recordOf, type Row } from '../card-values';
import { formatDate, Rows, text } from '../preview-format';
import { rowsOf } from '../undo-snapshot';
import {
JOURNEY_PHOTOS_LIMITS,
type AssistantChangePreview,
type JourneyPhotoLook,
type JourneyPhotosCard,
type JourneyPhotosEntry,
} from '@trek/shared';
/**
* The change cards of a journal's photos: one put on an entry or taken off
* it, photos moved between entries, an entry's photos in a new order, a
* caption, photos added from a library. Each shows the photos themselves (by
* their TREK photo id, for the thumbnail) and the entries they leave and
* reach, read with the user's rights from `get_journey`.
*/
/** What a held photo change looked up: the journal as `get_journey` answers it. */
export interface JournalPhotosLoaded {
journey: Row;
}
/** The tools these cards are for, each with what its card shows. */
const MODES = {
link_journey_photo: 'link',
unlink_journey_photo: 'unlink',
move_journey_photos: 'move',
reorder_journey_entry_photos: 'order',
set_journey_photo_caption: 'caption',
add_journey_provider_photos: 'add',
} as const satisfies Record<string, JourneyPhotosCard['mode']>;
export type JournalPhotosTool = keyof typeof MODES;
/** The entry a call is about: the one it changes, or the one the photos leave. */
function entryArg(args: ToolArgs): number | null {
return argId(args, 'entryId') ?? argId(args, 'fromEntryId');
}
/**
* At most two reads: the journal the call names, or, where it names only an
* entry, the entry first for its journal. In a journey's own conversation the
* session pins the journal, so the entry read and the journal read both reach it.
*/
export async function loadJournalPhotos(args: ToolArgs, read: CardReader): Promise<JournalPhotosLoaded | null> {
let journeyId = argId(args, 'journeyId');
const entryId = entryArg(args);
if (journeyId === null && entryId !== null) {
journeyId = argId(recordOf(recordOf(await read('get_journey_entry', { entryId })).journey), 'id');
}
const journey = recordOf(recordOf(await read('get_journey', journeyId === null ? {} : { journeyId })).journey);
return argId(journey, 'id') === null ? null : { journey };
}
function entriesOf(loaded: JournalPhotosLoaded | null): Row[] {
return rowsOf(loaded?.journey.entries);
}
function entryOf(loaded: JournalPhotosLoaded | null, entryId: number | null): Row | undefined {
return entryId === null ? undefined : entriesOf(loaded).find((entry) => entry.id === entryId);
}
/** The photos of an entry, by their journey photo id, in its order. */
function entryPhotos(entry: Row | undefined): Row[] {
return rowsOf(entry?.photos);
}
function galleryPhoto(loaded: JournalPhotosLoaded | null, journeyPhotoId: number | null): Row | undefined {
return rowsOf(loaded?.journey.gallery).find((photo) => photo.id === journeyPhotoId);
}
function idsOf(value: unknown): number[] {
return Array.isArray(value) ? value.filter((id): id is number => Number.isInteger(id) && id > 0) : [];
}
/** A photo as a card shows it; null for a row without a TREK photo. */
function photoLook(row: Row | undefined): JourneyPhotoLook | null {
const photoId = argId(row ?? {}, 'photo_id');
if (!row || photoId === null) return null;
return {
photoId,
video: row.media_type === 'video',
caption: clipped(row.caption, JOURNEY_PHOTOS_LIMITS.caption),
};
}
function entryRef(entry: Row | undefined, entryId: number | null): JourneyPhotosEntry | null {
if (entryId === null) return null;
const date =
typeof entry?.entry_date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(entry.entry_date) ? entry.entry_date : null;
const title =
clipped(entry?.title, JOURNEY_PHOTOS_LIMITS.title) ?? clipped(entry?.location_name, JOURNEY_PHOTOS_LIMITS.title);
return { entryId, title, date };
}
/** The photos a call concerns, as the card draws them, in the order the change leaves them. */
function photosOf(tool: JournalPhotosTool, args: ToolArgs, loaded: JournalPhotosLoaded | null): Row[] {
const source = entryPhotos(entryOf(loaded, entryArg(args)));
const byId = (rows: Row[], ids: number[]) => ids.flatMap((id) => rows.filter((row) => row.id === id));
if (tool === 'link_journey_photo' || tool === 'set_journey_photo_caption') {
const photo = galleryPhoto(loaded, argId(args, 'journey_photo_id'));
return photo ? [photo] : [];
}
if (tool === 'unlink_journey_photo') return byId(source, [argId(args, 'journey_photo_id') ?? 0]);
if (tool === 'move_journey_photos') return byId(source, idsOf(args.photoIds));
if (tool === 'reorder_journey_entry_photos') return byId(source, idsOf(args.orderedIds));
return [];
}
/** How many photos the call names: its ids, or for a library add the assets it brings. */
function namedCount(tool: JournalPhotosTool, args: ToolArgs): number {
if (tool === 'move_journey_photos') return idsOf(args.photoIds).length;
if (tool === 'reorder_journey_entry_photos') return idsOf(args.orderedIds).length;
if (tool === 'add_journey_provider_photos') return Array.isArray(args.asset_ids) ? args.asset_ids.length : 0;
return 1;
}
/** The card under a held photo change; null without a journal to name. */
export function journalPhotosCard(
tool: JournalPhotosTool,
args: ToolArgs,
loaded: JournalPhotosLoaded | null,
): JourneyPhotosCard | null {
const journeyId = argId(loaded?.journey ?? {}, 'id');
if (journeyId === null) return null;
const mode = MODES[tool];
const looks = photosOf(tool, args, loaded).flatMap((row) => {
const look = photoLook(row);
return look ? [look] : [];
});
const shown = looks.slice(0, JOURNEY_PHOTOS_LIMITS.photos);
const entryId = entryArg(args);
const leaves = mode === 'move' || mode === 'unlink';
const toId = mode === 'move' ? argId(args, 'toEntryId') : entryId;
const captioned = galleryPhoto(loaded, argId(args, 'journey_photo_id'));
return {
kind: 'journeyPhotos',
journeyId,
mode,
from: leaves ? entryRef(entryOf(loaded, entryId), entryId) : null,
to: mode === 'unlink' || mode === 'caption' ? null : entryRef(entryOf(loaded, toId), toId),
photos: shown,
more: Math.max(0, namedCount(tool, args) - shown.length),
kept: mode === 'move' && args.keep_in_source === true,
caption:
mode === 'caption'
? {
before: clipped(captioned?.caption, JOURNEY_PHOTOS_LIMITS.caption),
after: clipped(args.caption, JOURNEY_PHOTOS_LIMITS.caption),
}
: null,
};
}
/** What an entry is called on a row: its title, else its place, else its day. */
function entryName(ctx: PreviewContext, entry: Row | undefined): string {
const day = typeof entry?.entry_date === 'string' ? formatDate(ctx, entry.entry_date) : '';
return text(entry?.title) || text(entry?.location_name) || day;
}
/**
* The words of a photo change: the entry it is about, the number of photos,
* a caption old to new. A link or a move is undoable only where none of its
* photos was on the entry they reach already: the way back would take off a
* photo the change never put there.
*/
export function describeJournalPhotos(
tool: JournalPhotosTool,
ctx: PreviewContext,
args: ToolArgs,
loaded: JournalPhotosLoaded | null,
): Described {
const rows = new Rows();
const entry = entryOf(loaded, entryArg(args));
const count = namedCount(tool, args);
const base = { dayId: null, placeIds: [], domain: 'journey' };
if (tool === 'set_journey_photo_caption') {
const photo = galleryPhoto(loaded, argId(args, 'journey_photo_id'));
rows.change('text', text(photo?.caption), text(args.caption));
return { subject: text(loaded?.journey.title), rows, ...base };
}
rows.rows.push({ op: 'add', labelKey: 'field', field: 'photos', before: null, after: String(count) });
if (tool === 'move_journey_photos') {
const target = entryOf(loaded, argId(args, 'toEntryId'));
const from = entryName(ctx, entry);
rows.rows.push({
op: 'move',
labelKey: 'field',
field: 'entry',
before: from || null,
after: entryName(ctx, target),
});
const already = new Set(entryPhotos(target).map((photo) => photo.id));
const undoable = !idsOf(args.photoIds).some((id) => already.has(id));
return { subject: entryName(ctx, target) || entryName(ctx, entry), rows, ...base, undoable };
}
if (tool === 'link_journey_photo') {
const already = entryPhotos(entry).some((photo) => photo.id === argId(args, 'journey_photo_id'));
return { subject: entryName(ctx, entry), rows, ...base, undoable: !already };
}
return { subject: entryName(ctx, entry) || text(loaded?.journey.title), rows, ...base };
}
/**
* The card of an applied photo change: photos added from a library as they
* landed (their TREK photo ids are known only now); every other change keeps
* the card it was held with.
*/
export function journalPhotosApplied(
tool: JournalPhotosTool,
result: unknown,
preview: AssistantChangePreview | null,
): JourneyPhotosCard | null {
const held = preview?.card?.kind === 'journeyPhotos' ? preview.card : null;
if (tool !== 'add_journey_provider_photos' || !held) return null;
const looks = rowsOf(recordOf(result).photos).flatMap((row) => {
const look = photoLook(row);
return look ? [look] : [];
});
const photos = looks.slice(0, JOURNEY_PHOTOS_LIMITS.photos);
return { ...held, photos, more: Math.max(0, looks.length - photos.length) };
}