mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
371 lines
16 KiB
TypeScript
371 lines
16 KiB
TypeScript
import type {
|
|
AssistantApplied,
|
|
AssistantChangeEffect,
|
|
AssistantChangeLabelKey,
|
|
AssistantChangePreview,
|
|
AssistantFoundPlace,
|
|
AssistantMessage,
|
|
AssistantMessagePart,
|
|
AssistantPlaces,
|
|
AssistantWeatherDay,
|
|
TrekCard,
|
|
} from '@trek/shared';
|
|
|
|
import { CARD_RULES } from './cards/cardLooks';
|
|
import type { CardEntry } from './cards/cardTypes';
|
|
|
|
/**
|
|
* How an answer is laid out: its stored parts read as blocks. Text stays
|
|
* text; the reading tools of a run sit together as one row of steps; the
|
|
* changes the model proposes in one go sit together as one card, each with
|
|
* its preview and, once applied, its receipt. The places a search found sit
|
|
* as one strip, the newest search's, and the weather the steps looked up as
|
|
* one row of days. The cards of reading tools sit as blocks of their group; a
|
|
* write's card rides on its change. Pure, so the window and the phone screen
|
|
* lay an answer out the same way and a test can pin it.
|
|
*/
|
|
|
|
/** Where the settings card for the user's own AI connection lives. */
|
|
export const AI_SETTINGS_PATH = '/settings?tab=integrations';
|
|
|
|
type ToolCallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
|
|
|
/** One reading tool the assistant ran or runs. */
|
|
export interface ToolStep {
|
|
toolCallId: string;
|
|
toolName: string;
|
|
/** Still waiting for its result while the answer is being written. */
|
|
running: boolean;
|
|
}
|
|
|
|
/**
|
|
* Where a proposed change stands: waiting for the user, being applied,
|
|
* applied, failed, discarded, or closed without a decision (expired, or the
|
|
* turn was stopped).
|
|
*/
|
|
export type ChangeState = 'pending' | 'applying' | 'applied' | 'failed' | 'discarded' | 'expired';
|
|
|
|
export interface ChangeItem {
|
|
toolCallId: string;
|
|
/** What the card says about it; null until the preview arrived. */
|
|
preview: AssistantChangePreview | null;
|
|
/** The receipt of an applied change. */
|
|
applied: AssistantApplied | null;
|
|
state: ChangeState;
|
|
/** What the change does as a card: the applied one once it came, else the preview's; null without one. */
|
|
card: TrekCard | null;
|
|
}
|
|
|
|
/** The answer cards of one group, side by side. */
|
|
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
|
|
|
|
type MessageBlock =
|
|
| { kind: 'text'; key: string; text: string }
|
|
| { kind: 'steps'; key: string; steps: ToolStep[] }
|
|
| { kind: 'change'; key: string; items: ChangeItem[] }
|
|
| { kind: 'places'; key: string; found: AssistantPlaces }
|
|
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] }
|
|
| CardsBlock;
|
|
|
|
/** How cards are laid out: the group each joins, and which ones give way to a change they served. */
|
|
export interface CardRules {
|
|
group(card: TrekCard): string;
|
|
yieldsToChange(answer: TrekCard, change: TrekCard): boolean;
|
|
}
|
|
|
|
/** The change "+ Day 3" on a found place makes; the server holds it without a model turn. */
|
|
const FOUND_PLACE_TOOL = 'add_found_place';
|
|
|
|
/** Two points this close (about ten metres) are the same place. */
|
|
const SAME_SPOT_DEG = 1e-4;
|
|
|
|
/** A found place one of the answer's own changes already proposes, so its card would say the same twice. */
|
|
function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeEffect[]): boolean {
|
|
return effects.some((effect) => {
|
|
if (effect.placeId !== null && effect.placeId === place.inTripPlaceId) return true;
|
|
const spot = effect.place;
|
|
return (
|
|
spot !== null && Math.abs(spot.lat - place.lat) < SAME_SPOT_DEG && Math.abs(spot.lng - place.lng) < SAME_SPOT_DEG
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
|
|
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
|
|
const byDate = new Map<string, AssistantWeatherDay>();
|
|
for (const part of parts) {
|
|
if (part.type === 'data-trek-weather') for (const day of part.data.days) byDate.set(day.date, day);
|
|
}
|
|
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
}
|
|
|
|
/** The handles one place goes by: its ids, and its name at its spot (to about 100 m). */
|
|
function placeKeys(place: AssistantFoundPlace): string[] {
|
|
const ids = [place.googlePlaceId && `g:${place.googlePlaceId}`, place.osmId && `o:${place.osmId}`];
|
|
const spot = `n:${place.name.trim().toLowerCase()}@${place.lat.toFixed(3)},${place.lng.toFixed(3)}`;
|
|
return [...ids.filter((key): key is string => !!key), spot];
|
|
}
|
|
|
|
/**
|
|
* The places all searches of an answer found, in the order they came, each
|
|
* once (also when two searches name it by different ids, as long as name and
|
|
* spot agree), with what the searches left out counted together.
|
|
*/
|
|
function allFound(parts: AssistantMessage['parts']): AssistantPlaces {
|
|
const seen = new Set<string>();
|
|
const places: AssistantFoundPlace[] = [];
|
|
let more = 0;
|
|
let toolCallId = '';
|
|
for (const part of parts) {
|
|
if (part.type !== 'data-trek-places') continue;
|
|
toolCallId = part.data.toolCallId;
|
|
more += part.data.more;
|
|
for (const place of part.data.places) {
|
|
const keys = placeKeys(place);
|
|
if (keys.some((key) => seen.has(key))) continue;
|
|
keys.forEach((key) => seen.add(key));
|
|
places.push(place);
|
|
}
|
|
}
|
|
return { toolCallId, places, more };
|
|
}
|
|
|
|
/**
|
|
* Adds a change to the change block before it, or starts one. A suggestion
|
|
* from the strip is a block of its own, decided apart from the changes before
|
|
* and after it, even where the answer joins them.
|
|
*/
|
|
function pushChange(blocks: MessageBlock[], item: ChangeItem, fromStrip: ReadonlySet<string>): void {
|
|
const last = blocks[blocks.length - 1];
|
|
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
|
|
const apart = fromStrip.has(item.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
|
|
if (last?.kind === 'change' && !apart) last.items.push(item);
|
|
else blocks.push({ kind: 'change', key: `change-${item.toolCallId}`, items: [item] });
|
|
}
|
|
|
|
/** The steps block a new step joins: the last one, past any strip or cards shown after it. */
|
|
function openSteps(blocks: readonly MessageBlock[]): Extract<MessageBlock, { kind: 'steps' }> | null {
|
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
const block = blocks[i];
|
|
if (block?.kind === 'steps') return block;
|
|
if (block?.kind !== 'places' && block?.kind !== 'weather' && block?.kind !== 'cards') return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Adds an answer card to the last cards block of its group when only steps lie between them, or starts one. */
|
|
function pushCard(blocks: MessageBlock[], entry: CardEntry, group: string): void {
|
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
const block = blocks[i];
|
|
if (block?.kind === 'steps') continue;
|
|
if (block?.kind === 'cards' && block.group === group) {
|
|
block.entries.push(entry);
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
blocks.push({ kind: 'cards', key: `cards-${entry.toolCallId}`, group, entries: [entry] });
|
|
}
|
|
|
|
/** The blocks without the answer cards that give way to one of the message's changes, and without emptied ones. */
|
|
function withoutYielded(blocks: MessageBlock[], rules: CardRules): MessageBlock[] {
|
|
const changes = blocks.flatMap((b) => (b.kind === 'change' ? b.items.flatMap((i) => (i.card ? [i.card] : [])) : []));
|
|
if (changes.length === 0) return blocks;
|
|
return blocks.flatMap((block): MessageBlock[] => {
|
|
if (block.kind !== 'cards') return [block];
|
|
const entries = block.entries.filter((e) => !changes.some((change) => rules.yieldsToChange(e.card, change)));
|
|
return entries.length > 0 ? [{ ...block, entries }] : [];
|
|
});
|
|
}
|
|
|
|
/** The change a call holds, with its preview, receipt and card; null for a reading step. */
|
|
function changeItemOf(
|
|
part: ToolCallPart,
|
|
preview: AssistantChangePreview | null,
|
|
receipts: ReadonlyMap<string, AssistantApplied>,
|
|
cards: ReadonlyMap<string, TrekCard>
|
|
): ChangeItem | null {
|
|
if (!part.approval && !preview) return null;
|
|
const applied = receipts.get(part.toolCallId) ?? null;
|
|
return {
|
|
toolCallId: part.toolCallId,
|
|
preview,
|
|
applied,
|
|
state: changeState(part, applied),
|
|
card: cards.get(part.toolCallId) ?? preview?.card ?? null,
|
|
};
|
|
}
|
|
|
|
function changeState(part: ToolCallPart, applied: AssistantApplied | null): ChangeState {
|
|
const approval = part.approval;
|
|
if (approval?.resolution) return 'expired';
|
|
if (approval?.approved === false) return 'discarded';
|
|
if (approval?.approved === true) {
|
|
if (part.isError) return 'failed';
|
|
return applied || part.result !== undefined ? 'applied' : 'applying';
|
|
}
|
|
return 'pending';
|
|
}
|
|
|
|
/**
|
|
* The blocks of a message, in the order its parts came. `writing` says the
|
|
* message is still being streamed, so a step without a result is running and
|
|
* not merely unanswered.
|
|
*
|
|
* The places of all its searches join into one strip where the newest search
|
|
* sat, each place once, without the ones a change the model proposed in the
|
|
* answer already holds; the weather of all steps joins
|
|
* into one row where the first look sat. A strip does not split the steps
|
|
* around it: a step after it joins the row of steps before it.
|
|
*
|
|
* A reading tool's card joins the cards block of its group (by `rules`, the
|
|
* kinds' looks unless a test hands others) when only steps lie between them;
|
|
* a card under a change's id is that change's and forms no block. Last,
|
|
* answer cards that give way to one of the message's changes are left out.
|
|
*/
|
|
export function messageBlocks(
|
|
message: AssistantMessage,
|
|
writing: boolean,
|
|
rules: CardRules = CARD_RULES
|
|
): MessageBlock[] {
|
|
const previews = new Map<string, AssistantChangePreview>();
|
|
const receipts = new Map<string, AssistantApplied>();
|
|
const cards = new Map<string, TrekCard>();
|
|
const changeIds = new Set<string>();
|
|
const effects: AssistantChangeEffect[] = [];
|
|
// A place the user suggested from the strip stays on it, marked as suggested.
|
|
const fromStrip = new Set(
|
|
message.parts.flatMap((p) => (p.type === 'tool-call' && p.toolName === FOUND_PLACE_TOOL ? [p.toolCallId] : []))
|
|
);
|
|
let lastPlaces = -1;
|
|
let firstWeather = -1;
|
|
message.parts.forEach((part, index) => {
|
|
if (part.type === 'data-trek-change') {
|
|
previews.set(part.data.toolCallId, part.data);
|
|
if (part.data.effect && !fromStrip.has(part.data.toolCallId)) effects.push(part.data.effect);
|
|
changeIds.add(part.data.toolCallId);
|
|
} else if (part.type === 'data-trek-applied') receipts.set(part.data.toolCallId, part.data);
|
|
else if (part.type === 'data-trek-places') lastPlaces = index;
|
|
else if (part.type === 'data-trek-weather' && firstWeather < 0) firstWeather = index;
|
|
else if (part.type === 'data-trek-card' && part.data.card) cards.set(part.data.toolCallId, part.data.card);
|
|
else if (part.type === 'tool-call' && part.approval) changeIds.add(part.toolCallId);
|
|
});
|
|
|
|
const blocks: MessageBlock[] = [];
|
|
message.parts.forEach((part, index) => {
|
|
if (part.type === 'text') {
|
|
if (part.text.trim()) blocks.push({ kind: 'text', key: `text-${index}`, text: part.text });
|
|
return;
|
|
}
|
|
if (part.type === 'data-trek-places' && index === lastPlaces) {
|
|
const found = allFound(message.parts);
|
|
const places = found.places.filter((place) => !proposed(place, effects));
|
|
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
|
|
return;
|
|
}
|
|
if (part.type === 'data-trek-weather' && index === firstWeather) {
|
|
blocks.push({ kind: 'weather', key: `weather-${index}`, days: weatherDays(message.parts) });
|
|
return;
|
|
}
|
|
if (part.type === 'data-trek-card') {
|
|
const { toolCallId, card } = part.data;
|
|
// A card under a change's id is that change's; it rides on its row.
|
|
if (card && !changeIds.has(toolCallId)) pushCard(blocks, { toolCallId, card }, rules.group(card));
|
|
return;
|
|
}
|
|
if (part.type !== 'tool-call') return;
|
|
const item = changeItemOf(part, previews.get(part.toolCallId) ?? null, receipts, cards);
|
|
if (item) {
|
|
pushChange(blocks, item, fromStrip);
|
|
return;
|
|
}
|
|
const step: ToolStep = {
|
|
toolCallId: part.toolCallId,
|
|
toolName: part.toolName,
|
|
running: writing && part.result === undefined,
|
|
};
|
|
const steps = openSteps(blocks);
|
|
if (steps) steps.steps.push(step);
|
|
else blocks.push({ kind: 'steps', key: `steps-${part.toolCallId}`, steps: [step] });
|
|
});
|
|
return withoutYielded(blocks, rules);
|
|
}
|
|
|
|
/**
|
|
* Whether "+ Day" was already pressed on this found place in the answer: a
|
|
* suggestion from the strip names the trip's place by its id, a new one by
|
|
* the spot the search found it at.
|
|
*/
|
|
export function suggestedFromStrip(message: AssistantMessage, place: AssistantFoundPlace): boolean {
|
|
return message.parts.some((part) => {
|
|
if (part.type !== 'tool-call' || part.toolName !== FOUND_PLACE_TOOL) return false;
|
|
const args = part.args as { placeId?: unknown; lat?: unknown; lng?: unknown };
|
|
if (place.inTripPlaceId !== null && args.placeId === place.inTripPlaceId) return true;
|
|
return (
|
|
typeof args.lat === 'number' &&
|
|
typeof args.lng === 'number' &&
|
|
Math.abs(args.lat - place.lat) < SAME_SPOT_DEG &&
|
|
Math.abs(args.lng - place.lng) < SAME_SPOT_DEG
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Whether a change somewhere in the chat still waits for the user; nothing new can be suggested until then. */
|
|
export function hasUndecided(messages: readonly AssistantMessage[]): boolean {
|
|
return messages.some((m) =>
|
|
m.parts.some(
|
|
(p) => p.type === 'tool-call' && !!p.approval && p.approval.approved === undefined && !p.approval.resolution
|
|
)
|
|
);
|
|
}
|
|
|
|
/** How long an applied change can still be undone, in ms; 0 once it cannot. */
|
|
export function undoLeftMs(applied: AssistantApplied | null, now: number): number {
|
|
if (!applied?.undoUntil) return 0;
|
|
return Math.max(0, Date.parse(applied.undoUntil) - now);
|
|
}
|
|
|
|
/** The label of each field a change row can show. A table, so every key is named where it is read. */
|
|
export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }> = {
|
|
// A tool's own argument: the row names it in `field`, this is only its fallback.
|
|
field: { labelKey: 'assistant.change.label.field' },
|
|
order: { labelKey: 'assistant.change.label.order' },
|
|
name: { labelKey: 'assistant.change.label.name' },
|
|
description: { labelKey: 'assistant.change.label.description' },
|
|
address: { labelKey: 'assistant.change.label.address' },
|
|
coordinates: { labelKey: 'assistant.change.label.coordinates' },
|
|
category: { labelKey: 'assistant.change.label.category' },
|
|
day: { labelKey: 'assistant.change.label.day' },
|
|
time: { labelKey: 'assistant.change.label.time' },
|
|
endTime: { labelKey: 'assistant.change.label.endTime' },
|
|
notes: { labelKey: 'assistant.change.label.notes' },
|
|
website: { labelKey: 'assistant.change.label.website' },
|
|
phone: { labelKey: 'assistant.change.label.phone' },
|
|
email: { labelKey: 'assistant.change.label.email' },
|
|
openingHours: { labelKey: 'assistant.change.label.openingHours' },
|
|
price: { labelKey: 'assistant.change.label.price' },
|
|
currency: { labelKey: 'assistant.change.label.currency' },
|
|
stopType: { labelKey: 'assistant.change.label.stopType' },
|
|
text: { labelKey: 'assistant.change.label.text' },
|
|
icon: { labelKey: 'assistant.change.label.icon' },
|
|
color: { labelKey: 'assistant.change.label.color' },
|
|
position: { labelKey: 'assistant.change.label.position' },
|
|
quantity: { labelKey: 'assistant.change.label.quantity' },
|
|
weight: { labelKey: 'assistant.change.label.weight' },
|
|
bag: { labelKey: 'assistant.change.label.bag' },
|
|
packed: { labelKey: 'assistant.change.label.packed' },
|
|
dueDate: { labelKey: 'assistant.change.label.dueDate' },
|
|
priority: { labelKey: 'assistant.change.label.priority' },
|
|
assignee: { labelKey: 'assistant.change.label.assignee' },
|
|
type: { labelKey: 'assistant.change.label.type' },
|
|
location: { labelKey: 'assistant.change.label.location' },
|
|
link: { labelKey: 'assistant.change.label.link' },
|
|
confirmation: { labelKey: 'assistant.change.label.confirmation' },
|
|
checkIn: { labelKey: 'assistant.change.label.checkIn' },
|
|
checkOut: { labelKey: 'assistant.change.label.checkOut' },
|
|
stay: { labelKey: 'assistant.change.label.stay' },
|
|
members: { labelKey: 'assistant.change.label.members' },
|
|
route: { labelKey: 'assistant.change.label.route' },
|
|
difficulty: { labelKey: 'assistant.change.label.difficulty' },
|
|
};
|