mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
fix(assistant): green CI round, trip notes the assistant can keep, calmer change cards
- the assistant keeps facts for a trip (remember_for_trip) as a change card, applied into the user's trip notes; existing expenses can be changed - change card fields as a few icon chips with the rest folded under Details, the day band marks only what changes, the week grid as compact tiles - no map button on receipts of things that have no place on the map - tests follow the composer's accessible name, the receipt's status icon and the GL map's container; the hanging stream helper honours an early abort - Postgres probe: qualified columns in the upsert updates; entities restored to the generator's output; strict ratchet reads the SDK client as untyped
This commit is contained in:
@@ -161,7 +161,8 @@ describe('useLlmParsingConfig', () => {
|
||||
await act(async () => result.current.setVision('off'));
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
await act(() => result.current.save());
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls.at(-1)?.[1]).toEqual({
|
||||
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
|
||||
expect(calls[calls.length - 1]?.[1]).toEqual({
|
||||
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('AssistantChangeCard: the receipt', () => {
|
||||
const onShowOnMap = vi.fn();
|
||||
renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onShowOnMap });
|
||||
|
||||
expect(screen.getByText('Applied')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Applied' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
|
||||
expect(onShowOnMap).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'c1', dayId: 3 }));
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
|
||||
@@ -188,7 +188,7 @@ describe('AssistantChangeCard: the receipt', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
});
|
||||
expect(undo).toHaveBeenCalledWith(TRIP, 'c1');
|
||||
expect(screen.getByText('Undone')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Undone' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('AssistantChangeCard: the receipt', () => {
|
||||
renderCard([receipt('c1', null), { ...pending('c2', 'Ryoan-ji'), state: 'discarded' }]);
|
||||
expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
|
||||
expect(screen.getByText('Discarded')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Discarded' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AssistantChangePreview, AssistantChangeRow, AssistantChangeVerbKey } from '@trek/shared';
|
||||
import type { AssistantApplied, AssistantChangePreview, AssistantChangeVerbKey } from '@trek/shared';
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarArrowUp,
|
||||
CalendarMinus,
|
||||
CalendarPlus,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
Luggage,
|
||||
MapPin,
|
||||
MapPinPlus,
|
||||
NotebookPen,
|
||||
PencilLine,
|
||||
StickyNote,
|
||||
Ticket,
|
||||
@@ -27,10 +27,11 @@ import { useTripStore } from '../../store/tripStore';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantChangeFields from './AssistantChangeFields';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
import AssistantDayBand from './AssistantDayBand';
|
||||
import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { CHANGE_LABELS, type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
|
||||
import { type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { changePoints, type DayChange } from './dayAfterModel';
|
||||
import { useChangeHover } from './useChangeHover';
|
||||
@@ -78,8 +79,10 @@ const VERB_LOOK: Record<AssistantChangeVerbKey, { Icon: typeof Check; tone: Tone
|
||||
updatePackingItem: { Icon: Luggage, tone: 'info' },
|
||||
addTodo: { Icon: ListTodo, tone: 'success' },
|
||||
addBudgetItem: { Icon: Wallet, tone: 'success' },
|
||||
updateBudgetItem: { Icon: Wallet, tone: 'info' },
|
||||
addReservation: { Icon: Ticket, tone: 'success' },
|
||||
importBooking: { Icon: Ticket, tone: 'success' },
|
||||
rememberNote: { Icon: NotebookPen, tone: 'info' },
|
||||
};
|
||||
|
||||
/** How a decided change reads: one quiet mark, its words in the tooltip. */
|
||||
@@ -130,37 +133,6 @@ function ChangeHead({ preview, struck = false }: { preview: AssistantChangePrevi
|
||||
);
|
||||
}
|
||||
|
||||
/** One field as a badge that holds its label as a smaller badge; a change shows the old value struck through. */
|
||||
function FieldBadge({ row }: { row: AssistantChangeRow }) {
|
||||
const { t } = useTranslation();
|
||||
const empty = <span className="italic text-content-faint">{t('assistant.change.empty')}</span>;
|
||||
return (
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-secondary py-[3px] pe-2.5 ps-[3px] text-caption text-content">
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-card px-2 py-px text-content-muted">
|
||||
{t(CHANGE_LABELS[row.labelKey].labelKey)}
|
||||
</span>
|
||||
{row.op !== 'add' && (
|
||||
<>
|
||||
<bdi className="min-w-0 truncate text-content-faint line-through">{row.before || empty}</bdi>
|
||||
<ArrowRight size={11} aria-hidden="true" className="flex-shrink-0 text-content-faint rtl:-scale-x-100" />
|
||||
</>
|
||||
)}
|
||||
<bdi className="min-w-0 truncate">{row.after || empty}</bdi>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{rows.map((row, i) => (
|
||||
<FieldBadge key={`${row.labelKey}-${i}`} row={row} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The time now, ticking once more when the next undo window closes. */
|
||||
function useUndoClock(items: ChangeItem[]): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -192,6 +164,9 @@ function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string
|
||||
);
|
||||
}
|
||||
|
||||
/** What an applied change can be shown as on the map; a kept note, a todo or an expense has no place there. */
|
||||
const ON_MAP: ReadonlySet<AssistantApplied['entity']> = new Set(['place', 'assignment', 'day_note', 'reservation']);
|
||||
|
||||
function Receipt({
|
||||
item,
|
||||
undone,
|
||||
@@ -209,6 +184,7 @@ function Receipt({
|
||||
const receipt = undone ? RECEIPT.discarded : RECEIPT[item.state];
|
||||
const { Icon } = receipt;
|
||||
const applied = item.state === 'applied' && !undone ? item.applied : null;
|
||||
const mappable = applied !== null && ON_MAP.has(applied.entity);
|
||||
const settled = item.state === 'discarded' || item.state === 'expired' || undone;
|
||||
const statusLabel = t(undone ? 'assistant.change.undone' : receipt.labelKey);
|
||||
return (
|
||||
@@ -217,7 +193,7 @@ function Receipt({
|
||||
<div className={`min-w-0 flex-1 ${settled ? 'opacity-60' : ''}`}>
|
||||
<ChangeHead preview={item.preview} struck={settled} />
|
||||
</div>
|
||||
{applied && onShowOnMap && (
|
||||
{applied && mappable && onShowOnMap && (
|
||||
<Tooltip label={t('assistant.change.showOnMap')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
@@ -276,7 +252,7 @@ function PendingRow({
|
||||
<KindTile verbKey={item.preview?.verbKey ?? null} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChangeHead preview={item.preview} />
|
||||
{item.preview && <ChangeFields rows={item.preview.rows} />}
|
||||
{item.preview && <AssistantChangeFields rows={item.preview.rows} />}
|
||||
</div>
|
||||
{withSwitch && (
|
||||
<ToggleSwitch on={!excluded} onToggle={onToggle} label={t('assistant.change.include')} disabled={disabled} />
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Hash,
|
||||
LogIn,
|
||||
LogOut,
|
||||
type LucideIcon,
|
||||
Tag,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { CHANGE_LABELS } from './assistantParts';
|
||||
|
||||
/**
|
||||
* The fields of one change, read at a glance: the few that decide whether it
|
||||
* fits (when, what, what it costs) as quiet icon chips, a value that changes
|
||||
* as old to new, everything else folded behind one "Details" line. Positions
|
||||
* and colours never show; the map and the trip show those better.
|
||||
*/
|
||||
|
||||
/** The fields worth a chip of their own, each with its glyph, in this order. */
|
||||
const KEY_FIELDS: Partial<Record<AssistantChangeLabelKey, LucideIcon>> = {
|
||||
day: CalendarDays,
|
||||
time: Clock,
|
||||
endTime: Clock,
|
||||
checkIn: LogIn,
|
||||
checkOut: LogOut,
|
||||
dueDate: CalendarClock,
|
||||
category: Tag,
|
||||
price: Wallet,
|
||||
quantity: Hash,
|
||||
};
|
||||
const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
|
||||
|
||||
/** Fields that mean nothing written out. */
|
||||
const HIDDEN: ReadonlySet<AssistantChangeLabelKey> = new Set(['coordinates', 'position', 'icon', 'color', 'currency']);
|
||||
|
||||
/** "#3" is how a day comes from the server; it reads as "Day 3". */
|
||||
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
|
||||
if (row.labelKey === 'day' && value && /^#\d+$/.test(value)) return `${dayWord} ${value.slice(1)}`;
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
function Chip({ row }: { row: AssistantChangeRow }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
|
||||
const label = t(CHANGE_LABELS[row.labelKey].labelKey);
|
||||
const dayWord = t('assistant.week.day');
|
||||
const changed = row.op !== 'add' && !!row.before;
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md bg-surface-secondary px-1.5 py-0.5 text-caption text-content-secondary"
|
||||
>
|
||||
<Icon size={11} aria-label={label} className="flex-shrink-0 text-content-faint" />
|
||||
{changed && (
|
||||
<>
|
||||
<bdi className="min-w-0 truncate text-content-faint line-through">{shown(row, row.before, dayWord)}</bdi>
|
||||
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 text-content-faint rtl:-scale-x-100" />
|
||||
</>
|
||||
)}
|
||||
<bdi className="min-w-0 truncate font-medium text-content">{shown(row, row.after, dayWord)}</bdi>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A field behind "Details": its name, then the value, old to new where it changes. */
|
||||
function DetailLine({ row }: { row: AssistantChangeRow }) {
|
||||
const { t } = useTranslation();
|
||||
const changed = row.op !== 'add' && !!row.before;
|
||||
return (
|
||||
<div className="flex min-w-0 gap-2 text-caption">
|
||||
<span className="w-20 flex-shrink-0 truncate text-content-faint">{t(CHANGE_LABELS[row.labelKey].labelKey)}</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1 text-content-secondary">
|
||||
{changed && (
|
||||
<>
|
||||
<bdi className="min-w-0 truncate text-content-faint line-through">{row.before}</bdi>
|
||||
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 text-content-faint rtl:-scale-x-100" />
|
||||
</>
|
||||
)}
|
||||
<bdi className="min-w-0 truncate">{row.after || t('assistant.change.empty')}</bdi>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const visible = rows.filter((row) => !HIDDEN.has(row.labelKey) && row.labelKey !== 'name');
|
||||
const key = visible
|
||||
.filter((row) => row.labelKey in KEY_FIELDS && row.after)
|
||||
.sort((a, b) => KEY_ORDER.indexOf(a.labelKey) - KEY_ORDER.indexOf(b.labelKey));
|
||||
const rest = visible.filter((row) => !key.includes(row));
|
||||
if (key.length === 0 && rest.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-col gap-1.5">
|
||||
{key.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.map((row, i) => (
|
||||
<Chip key={`${row.labelKey}-${i}`} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rest.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="inline-flex items-center gap-1 self-start rounded-md text-caption text-content-faint transition-colors hover:text-content"
|
||||
>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
{t(open ? 'assistant.change.detailsHide' : 'assistant.change.details')}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="trek-assistant-enter flex flex-col gap-1 rounded-lg bg-surface-secondary px-2.5 py-2">
|
||||
{rest.map((row, i) => (
|
||||
<DetailLine key={`${row.labelKey}-${i}`} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ function seed(tripInPlanner = TRIP) {
|
||||
|
||||
async function input(): Promise<HTMLTextAreaElement> {
|
||||
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
|
||||
return (await screen.findByPlaceholderText('Ask anything about Paris')) as HTMLTextAreaElement;
|
||||
return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
const mentions = () => chatOf(useAssistantStore.getState(), TRIP).mentions;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import { ChevronDown, CircleCheck, Minus, Plus, TriangleAlert } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
@@ -20,8 +20,9 @@ import { useChangeHover } from './useChangeHover';
|
||||
|
||||
/**
|
||||
* The days a waiting card touches, as they would read once it ran: per day its
|
||||
* date and its stops in order as small named chips, so a glance shows where the
|
||||
* suggestion lands among the rest. The details (times, the way between stops,
|
||||
* date and its stops in order. What stays reads as quiet text; only what the
|
||||
* card adds is a filled pill and what it takes away is struck, so a glance
|
||||
* finds the suggestion among the rest. The details (times, the way between stops,
|
||||
* free time, a clash) stay folded away until asked for. Two days at most; the
|
||||
* rest are counted. Shown only while the planner holds this trip, since the day
|
||||
* plans come from it.
|
||||
@@ -33,26 +34,33 @@ const MAX_DAYS = 2;
|
||||
const MAX_CHIPS = 6;
|
||||
|
||||
const CHIP: Record<TimelineStop['status'], string> = {
|
||||
kept: 'border-transparent bg-surface-secondary text-content-muted',
|
||||
new: 'border-dashed border-success bg-success-soft text-content',
|
||||
removed: 'border-transparent text-content-faint line-through',
|
||||
kept: 'px-0.5 text-content-muted',
|
||||
new: 'rounded-full bg-accent px-2 py-0.5 font-medium text-accent-text shadow-sm',
|
||||
removed: 'rounded-full px-1.5 py-0.5 text-content-faint line-through',
|
||||
};
|
||||
|
||||
/** One stop of the day, named; the change it belongs to lights up with it. */
|
||||
function StopChip({ stop }: { stop: TimelineStop }) {
|
||||
const { tripId } = useAssistantView();
|
||||
const { lit, handlers } = useChangeHover(tripId, stop.toolCallId);
|
||||
const clash = stop.lead?.overlapsWith ? 'border-danger' : stopIssue(stop) ? 'border-warning' : '';
|
||||
const issue = stop.lead?.overlapsWith ? 'ring-2 ring-danger' : stopIssue(stop) ? 'ring-2 ring-warning' : '';
|
||||
const Mark = stop.status === 'new' ? Plus : stop.status === 'removed' ? Minus : null;
|
||||
return (
|
||||
<span
|
||||
{...(stop.toolCallId ? handlers : {})}
|
||||
className={`inline-flex max-w-[11rem] items-center rounded-full border px-2 py-0.5 text-caption transition-colors ${CHIP[stop.status]} ${clash} ${lit ? 'bg-surface-hover' : ''}`}
|
||||
className={`inline-flex max-w-[12rem] items-center gap-1 text-caption transition-all ${CHIP[stop.status]} ${issue} ${lit ? 'scale-[1.04]' : ''}`}
|
||||
>
|
||||
{Mark && <Mark size={11} strokeWidth={2.6} aria-hidden="true" className="flex-shrink-0" />}
|
||||
<bdi className="truncate">{stop.name}</bdi>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The thin rule between two stops, read as "then". */
|
||||
function Then() {
|
||||
return <span aria-hidden="true" className="h-px w-2 flex-shrink-0 bg-edge" />;
|
||||
}
|
||||
|
||||
function Lead({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const lead = stop.lead;
|
||||
@@ -111,11 +119,16 @@ function DayRow({ day, label, open }: { day: DayAfter; label: string; open: bool
|
||||
const chips = day.stops.slice(0, MAX_CHIPS);
|
||||
return (
|
||||
<div>
|
||||
<bdi className="block truncate text-caption font-medium text-content-muted">{label}</bdi>
|
||||
<bdi className="block truncate text-caption font-semibold uppercase tracking-wide text-content-faint">
|
||||
{label}
|
||||
</bdi>
|
||||
{!open && (
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
|
||||
{chips.map((stop) => (
|
||||
<StopChip key={stop.key} stop={stop} />
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1 gap-y-1.5">
|
||||
{chips.map((stop, i) => (
|
||||
<span key={stop.key} className="contents">
|
||||
{i > 0 && <Then />}
|
||||
<StopChip stop={stop} />
|
||||
</span>
|
||||
))}
|
||||
{day.stops.length > MAX_CHIPS && (
|
||||
<span className="text-caption tabular-nums text-content-faint">+{day.stops.length - MAX_CHIPS}</span>
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('AssistantSurface', () => {
|
||||
it('FE-AI-SURF-003: Enter sends what was typed, with the trip in the placeholder', async () => {
|
||||
seed([]);
|
||||
renderChat();
|
||||
const input = await screen.findByPlaceholderText('Ask anything about Kyoto');
|
||||
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
|
||||
fireEvent.change(input, { target: { value: 'Where should we eat?' } });
|
||||
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Where should we eat?');
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
@@ -105,7 +105,7 @@ describe('AssistantSurface', () => {
|
||||
},
|
||||
]);
|
||||
renderChat();
|
||||
const input = await screen.findByPlaceholderText('Ask anything about Kyoto');
|
||||
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
|
||||
fireEvent.change(input, { target: { value: 'Something else' } });
|
||||
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { WeekDay } from './useWeekDays';
|
||||
|
||||
/**
|
||||
* A plan over several days as one grid: a tile per day with how many changes
|
||||
* it gets, taken in or left out as a whole with one tap. The single changes
|
||||
* A plan over several days as one row of small tiles: a day per tile, its
|
||||
* number of changes in the corner, taken in or left out as a whole with one
|
||||
* tap. The single changes
|
||||
* stay below it, folded away until asked for.
|
||||
*/
|
||||
export default function AssistantWeekOverview({
|
||||
@@ -24,38 +23,43 @@ export default function AssistantWeekOverview({
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('assistant.week.title')}
|
||||
className="mb-2 grid gap-1.5"
|
||||
className="mb-2 grid gap-2 pt-1"
|
||||
style={{ gridTemplateColumns: `repeat(${Math.min(days.length, 7)}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{days.map((day, i) => {
|
||||
const on = day.ids.filter((id) => !excluded.has(id)).length;
|
||||
const taken = on > 0;
|
||||
const count = taken ? on : day.ids.length;
|
||||
const name = `${day.weekday ?? t('assistant.week.day')} ${day.n}`;
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={taken}
|
||||
aria-label={`${name}, ${t('assistant.week.changes', { count })}`}
|
||||
title={t('assistant.week.changes', { count })}
|
||||
onClick={() => onToggleDay(day.ids, !taken)}
|
||||
className={`trek-assistant-enter group/day relative flex min-w-0 flex-col items-center gap-0.5 rounded-xl border px-1 py-2 transition-all disabled:cursor-default ${
|
||||
className={`trek-assistant-enter relative flex min-w-0 flex-col items-center rounded-lg py-1.5 outline-none transition-all focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default ${
|
||||
taken
|
||||
? 'border-accent bg-accent-subtle text-content shadow-sm hover:-translate-y-px hover:shadow-md'
|
||||
: 'border-dashed border-edge text-content-faint hover:border-edge-secondary'
|
||||
? 'bg-accent text-accent-text shadow-sm hover:-translate-y-px hover:shadow-md'
|
||||
: 'bg-surface-secondary text-content-faint hover:text-content-muted'
|
||||
}`}
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<span className={`text-caption leading-tight ${taken ? 'opacity-75' : ''}`}>
|
||||
{day.weekday ?? t('assistant.week.day')}
|
||||
</span>
|
||||
<span className={`text-body font-semibold tabular-nums leading-tight ${taken ? '' : 'line-through'}`}>
|
||||
{day.n}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute end-1 top-1 grid h-3.5 w-3.5 place-items-center rounded-full ${
|
||||
taken ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'
|
||||
className={`absolute -end-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full px-1 text-caption font-semibold tabular-nums leading-none ring-2 ring-surface-card ${
|
||||
taken ? 'bg-surface-card text-content' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
{taken ? <Check size={9} strokeWidth={3} /> : <Minus size={9} strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="text-caption text-content-muted">{day.weekday ?? t('assistant.week.day')}</span>
|
||||
<span className="text-subtitle font-bold tabular-nums leading-none">{day.n}</span>
|
||||
<span className={`text-caption tabular-nums ${taken ? 'text-content-secondary' : ''}`}>
|
||||
{t('assistant.week.changes', { count: taken ? on : day.ids.length })}
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
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 { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { tripRepo } from '../../repo/tripRepo';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import type { Trip } from '../../types';
|
||||
@@ -62,6 +62,8 @@ const resume = vi.fn(async () => {});
|
||||
const cancel = vi.fn();
|
||||
|
||||
function seed(window: { open: boolean; minimized: boolean }, isRunning = false) {
|
||||
// A chat the test before unmounted stops itself on the way out; that call is not this test's.
|
||||
cancel.mockClear();
|
||||
useAssistantStore.setState({
|
||||
status: STATUS,
|
||||
window,
|
||||
@@ -135,9 +137,11 @@ describe('AssistantWindow', () => {
|
||||
expect(cancel).toHaveBeenCalledWith(TRIP.id);
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
|
||||
useAssistantStore.setState({
|
||||
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning: false } },
|
||||
});
|
||||
act(() =>
|
||||
useAssistantStore.setState({
|
||||
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning: false } },
|
||||
})
|
||||
);
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,10 @@ const STATUS: Partial<Record<AssistantMessage['status'], ThreadMessageLike['stat
|
||||
|
||||
function toPart(part: AssistantMessagePart): LikePart | null {
|
||||
if (part.type === 'text') return part.text ? { type: 'text', text: part.text } : null;
|
||||
// An attached file is drawn from the stored message; the runtime takes it as one more data part.
|
||||
if (part.type === 'attachment') {
|
||||
return { type: 'data-trek-attachment', data: { name: part.name, mime: part.mime, size: part.size } };
|
||||
}
|
||||
if (part.type !== 'tool-call') return part;
|
||||
return {
|
||||
type: 'tool-call',
|
||||
|
||||
@@ -47,7 +47,7 @@ function seed(tripInPlanner = TRIP) {
|
||||
|
||||
async function input(): Promise<HTMLTextAreaElement> {
|
||||
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
|
||||
return (await screen.findByPlaceholderText('Ask anything about Paris')) as HTMLTextAreaElement;
|
||||
return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
/** A drag as startPlaceDrag starts it; jsdom keeps the type's case, browsers lower it. */
|
||||
|
||||
@@ -47,6 +47,8 @@ const glMap = vi.hoisted(() => ({
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
||||
// The assistant's camera listens on the map's container for the user's own moves.
|
||||
getContainer: vi.fn(() => glCanvasContainer),
|
||||
getLayer: vi.fn().mockReturnValue(null),
|
||||
queryRenderedFeatures: vi.fn().mockReturnValue([]),
|
||||
querySourceFeatures: vi.fn().mockReturnValue([]),
|
||||
|
||||
@@ -16,22 +16,26 @@ function setup(hoveredId: string | null = null) {
|
||||
const attached: { el: HTMLElement; lng: number; lat: number; remove: ReturnType<typeof vi.fn> }[] = [];
|
||||
const pinsRef = { current: [] as { remove: () => void }[] };
|
||||
const onHover = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
// Stable across renders, as the map view hands them over: only `hovered` changes.
|
||||
const mapRef = { current: map };
|
||||
const attachPin = (el: HTMLElement, lng: number, lat: number) => {
|
||||
container.appendChild(el);
|
||||
const pin = { el, lng, lat, remove: vi.fn(() => el.remove()) };
|
||||
attached.push(pin);
|
||||
return pin;
|
||||
};
|
||||
const hook = renderHook(
|
||||
({ hovered }) =>
|
||||
useAssistantGhostMarkersGL({
|
||||
mapRef: { current: map },
|
||||
mapRef,
|
||||
mapReady: true,
|
||||
pinsRef,
|
||||
attachPin: (el, lng, lat) => {
|
||||
container.appendChild(el);
|
||||
const pin = { el, lng, lat, remove: vi.fn(() => el.remove()) };
|
||||
attached.push(pin);
|
||||
return pin;
|
||||
},
|
||||
attachPin,
|
||||
ghosts: GHOSTS,
|
||||
hoveredId: hovered,
|
||||
onHover,
|
||||
onClick: vi.fn(),
|
||||
onClick,
|
||||
}),
|
||||
{ initialProps: { hovered: hoveredId } }
|
||||
);
|
||||
|
||||
@@ -1013,8 +1013,9 @@ describe('IntegrationsTab – failure toasts', () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<IntegrationsTab />);
|
||||
const off = render(<IntegrationsTab />);
|
||||
expect(screen.queryByText('AI assistant')).not.toBeInTheDocument();
|
||||
off.unmount();
|
||||
|
||||
enableAddons(['ai_assistant']);
|
||||
const { unmount } = render(<IntegrationsTab />);
|
||||
|
||||
@@ -31,7 +31,8 @@ describe('ClaudeConnectorCard', () => {
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
const qr = screen.getByAltText('QR code to add TREK in Claude');
|
||||
expect(qr.getAttribute('src')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
|
||||
expect(container.querySelector('rect')).toBeNull();
|
||||
// The QR code's own markup never lands in the page; icons may draw rects of their own.
|
||||
expect(container.querySelector('rect[width="1"][height="1"]')).toBeNull();
|
||||
expect(document.getElementById('claude-connector')).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -46,12 +46,18 @@ async function* streamOf(chunks: UiStreamChunk[]): AsyncGenerator<UiStreamChunk>
|
||||
for (const chunk of chunks) yield chunk;
|
||||
}
|
||||
|
||||
/** A stream that sends `first` and then waits until its request is aborted. */
|
||||
/**
|
||||
* A stream that sends `first` and then waits until its request is aborted. An
|
||||
* abort that came before it got that far ends it at once: the event fired
|
||||
* already, and a listener added after it would wait for ever.
|
||||
*/
|
||||
async function* hangingStream(first: UiStreamChunk[], signal: AbortSignal): AsyncGenerator<UiStreamChunk> {
|
||||
yield* first;
|
||||
await new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
|
||||
);
|
||||
await new Promise((_, reject) => {
|
||||
const stop = () => reject(new DOMException('Aborted', 'AbortError'));
|
||||
if (signal.aborted) stop();
|
||||
else signal.addEventListener('abort', stop);
|
||||
});
|
||||
}
|
||||
|
||||
const chat = () => chatOf(useAssistantStore.getState(), TRIP);
|
||||
@@ -126,6 +132,8 @@ describe('assistantStore: a turn', () => {
|
||||
const running = store().send(TRIP, 'Plan it');
|
||||
await vi.waitFor(() => expect(chat().isRunning).toBe(true));
|
||||
await vi.waitFor(() => expect(chat().abort).not.toBeNull());
|
||||
// Stopped once the answer has begun, as a user would.
|
||||
await vi.waitFor(() => expect(chat().messages[1]?.parts).toEqual([{ type: 'text', text: 'Let me' }]));
|
||||
|
||||
store().cancel(TRIP);
|
||||
await running;
|
||||
|
||||
@@ -270,6 +270,8 @@ function onChunk(ctx: Ctx, tripId: number, chunk: UiStreamChunk) {
|
||||
} else if (chunk.type === 'data-trek-applied') {
|
||||
ctx.set({ lastApplied: { tripId, applied: chunk.data } });
|
||||
noteAddedPlace(ctx, tripId, chunk.data);
|
||||
// A kept note lives in the user's settings; read them again so the notes dialog shows it.
|
||||
if (chunk.data.entity === 'memory') void useSettingsStore.getState().loadSettings();
|
||||
} else if (chunk.type === 'data-trek-activity') {
|
||||
patch(ctx, tripId, { live: { activity: chunk.data } });
|
||||
} else if (chunk.type === 'tool-output-available' || chunk.type === 'tool-output-error') {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// The MCP SDK's client and in-memory transport resolve, through the `paths`
|
||||
// redirect in tsconfig.json, to its CommonJS build, which ships no type
|
||||
// definitions next to it, so the build reads them as untyped modules. Only
|
||||
// the strict ratchet (tsconfig.strict.json) loads this file: it declares the
|
||||
// two modules untyped there too, so an import of them is not counted as an
|
||||
// error the build does not have. The build and the tests never see it.
|
||||
|
||||
declare module '@modelcontextprotocol/sdk/client/index';
|
||||
declare module '@modelcontextprotocol/sdk/inMemory';
|
||||
@@ -21,7 +21,9 @@ export class AssistantMessages {
|
||||
export const AssistantMessagesSchema = defineEntity({
|
||||
class: AssistantMessages,
|
||||
repository: () => AssistantMessagesRepository,
|
||||
indexes: [{ name: 'idx_assistant_messages_thread', properties: ['thread_id', 'id'] }],
|
||||
indexes: [
|
||||
{ name: 'idx_assistant_messages_thread', properties: ['thread_id', 'id'] },
|
||||
],
|
||||
properties: {
|
||||
id: p.integer().primary(),
|
||||
thread: () => p.manyToOne(AssistantThreads).ref().deleteRule('cascade').hidden(),
|
||||
|
||||
@@ -30,8 +30,7 @@ export const AssistantToolCallsSchema = defineEntity({
|
||||
id: p.text().primary(),
|
||||
thread: () => p.manyToOne(AssistantThreads).ref().deleteRule('cascade').hidden(),
|
||||
thread_id: p.integer().persist(false),
|
||||
message: () =>
|
||||
p.manyToOne(AssistantMessages).ref().deleteRule('cascade').hidden().index('idx_assistant_tool_calls_message'),
|
||||
message: () => p.manyToOne(AssistantMessages).ref().deleteRule('cascade').hidden().index('idx_assistant_tool_calls_message'),
|
||||
message_id: p.integer().persist(false).index('idx_assistant_tool_calls_message'),
|
||||
provider_call_id: p.text(),
|
||||
tool_name: p.text(),
|
||||
|
||||
@@ -117,7 +117,7 @@ export class AiConnectionsRepository extends TrekRepository<AiConnections> {
|
||||
: oc.column('user_id').where('user_id', 'is not', null);
|
||||
return target.doUpdateSet({
|
||||
...columns,
|
||||
secrets_version: (eb) => eb('secrets_version', '+', 1),
|
||||
secrets_version: (eb) => eb('ai_connections.secrets_version', '+', 1),
|
||||
status: 'ok',
|
||||
updated_at: () => this.now(),
|
||||
});
|
||||
|
||||
@@ -35,9 +35,9 @@ export class AssistantUsageRepository extends TrekRepository<AssistantUsage> {
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['user_id', 'day', 'paid_by']).doUpdateSet({
|
||||
turns: (eb) => eb('turns', '+', eb.ref('excluded.turns')),
|
||||
input_tokens: (eb) => eb('input_tokens', '+', eb.ref('excluded.input_tokens')),
|
||||
output_tokens: (eb) => eb('output_tokens', '+', eb.ref('excluded.output_tokens')),
|
||||
turns: (eb) => eb('assistant_usage.turns', '+', eb.ref('excluded.turns')),
|
||||
input_tokens: (eb) => eb('assistant_usage.input_tokens', '+', eb.ref('excluded.input_tokens')),
|
||||
output_tokens: (eb) => eb('assistant_usage.output_tokens', '+', eb.ref('excluded.output_tokens')),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
|
||||
@@ -13,7 +13,7 @@ export {
|
||||
} from './helpers';
|
||||
export { McpRegistryService } from './mcp-registry.service';
|
||||
export { McpModule } from './mcp.module';
|
||||
export { McpRegistry, type McpRegistryOptions } from './registry';
|
||||
export { createLocalMcpServer, McpRegistry, type McpRegistryOptions } from './registry';
|
||||
export { createTestRegistry } from './testing';
|
||||
export {
|
||||
type McpAccess,
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ToolOptions,
|
||||
} from './types';
|
||||
import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -478,3 +478,8 @@ export class McpRegistry {
|
||||
registrar.registerPrompt(options.name, config, cb);
|
||||
}
|
||||
}
|
||||
|
||||
/** A server of its own for a client in the same process (the assistant's tool session), to attach a registry to. */
|
||||
export function createLocalMcpServer(name: string): McpServer {
|
||||
return new McpServer({ name, version: '1.0.0' });
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ export function renderTripCard(card: TripCard): string {
|
||||
type UserPrefs = Readonly<Record<string, unknown>>;
|
||||
|
||||
/** What a traveller writes for the assistant to keep in mind on one trip; longer notes are cut. */
|
||||
const MAX_MEMORY_CHARS = 2_000;
|
||||
export const MAX_MEMORY_CHARS = 2_000;
|
||||
|
||||
/** The notes the user keeps for this trip (`assistant_memory`, by trip id), trimmed; null when there are none. */
|
||||
export function tripMemoryOf(prefs: UserPrefs, tripId: number): string | null {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AiToolSpec } from '../ai-connections/ai-chat-client.interface';
|
||||
import type { SettingsService } from '../settings/settings.service';
|
||||
import { MAX_MEMORY_CHARS, tripMemoryOf } from './assistant-context';
|
||||
import { REMEMBER_TOOL } from './assistant-tools.constants';
|
||||
import type { ToolRun } from './assistant-tools.service';
|
||||
|
||||
/** The longest single note the model may keep. */
|
||||
const MAX_NOTE_CHARS = 500;
|
||||
|
||||
/** How the model is offered the trip notes: one short fact per call, confirmed by the user like any change. */
|
||||
export const REMEMBER_SPEC: AiToolSpec = {
|
||||
name: REMEMBER_TOOL,
|
||||
description:
|
||||
"Keep a fact or wish about this trip in the user's trip notes, which come along with every later conversation " +
|
||||
'about this trip (for example "we travel as two", "vegetarian", "no museums before 10"). Call it whenever the ' +
|
||||
'user asks you to remember something, instead of saying you cannot. The user confirms it like any change.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
note: {
|
||||
type: 'string',
|
||||
maxLength: MAX_NOTE_CHARS,
|
||||
description: "The fact as one short sentence, in the user's language.",
|
||||
},
|
||||
},
|
||||
required: ['note'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** The note a call carries, trimmed; null when there is none. */
|
||||
export function noteOf(args: Record<string, unknown>): string | null {
|
||||
const note = typeof args.note === 'string' ? args.note.trim().slice(0, MAX_NOTE_CHARS) : '';
|
||||
return note || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the note to this trip's notes, below what is there. A note already
|
||||
* kept is not added twice; notes that would run past the limit are refused,
|
||||
* so nothing the user wrote is cut off.
|
||||
*/
|
||||
export async function rememberForTrip(
|
||||
settings: SettingsService,
|
||||
userId: number,
|
||||
tripId: number,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<ToolRun> {
|
||||
const note = noteOf(args);
|
||||
if (!note) return { ok: false, value: 'There is nothing to remember.' };
|
||||
const prefs = await settings.getUserSettings(userId);
|
||||
const current = tripMemoryOf(prefs, tripId);
|
||||
if (current?.split('\n').some((line) => line.trim() === note)) return { ok: true, value: { notes: current } };
|
||||
const next = current ? `${current}\n${note}` : note;
|
||||
if (next.length > MAX_MEMORY_CHARS) {
|
||||
return { ok: false, value: 'The trip notes are full. Ask the user to tidy them up in the chat menu first.' };
|
||||
}
|
||||
const all = prefs.assistant_memory && typeof prefs.assistant_memory === 'object' ? prefs.assistant_memory : {};
|
||||
await settings.upsertSetting(userId, 'assistant_memory', { ...all, [String(tripId)]: next });
|
||||
return { ok: true, value: { notes: next } };
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export const ASSISTANT_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>>
|
||||
update_packing_item: { verbKey: 'updatePackingItem', entity: 'packing' },
|
||||
create_todo: { verbKey: 'addTodo', entity: 'todo' },
|
||||
create_budget_item: { verbKey: 'addBudgetItem', entity: 'budget' },
|
||||
update_budget_item: { verbKey: 'updateBudgetItem', entity: 'budget' },
|
||||
create_reservation: { verbKey: 'addReservation', entity: 'reservation' },
|
||||
};
|
||||
|
||||
@@ -105,14 +106,22 @@ export const IMPORT_BOOKING_TOOL = 'import_booking';
|
||||
*/
|
||||
export const ADD_FOUND_PLACE_TOOL = 'add_found_place';
|
||||
|
||||
/**
|
||||
* A fact or wish the user wants kept for this trip ("we are two"). It is no
|
||||
* MCP tool: the server offers it to the model itself, holds it as a change
|
||||
* card like any write and, once applied, adds it to the user's trip notes,
|
||||
* which come along with every later turn about the trip.
|
||||
*/
|
||||
export const REMEMBER_TOOL = 'remember_for_trip';
|
||||
|
||||
/** The model's tools a found place is added through: a new place for the day, or one the trip has already. */
|
||||
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set(['create_and_assign_place', 'assign_place_to_day']);
|
||||
|
||||
/**
|
||||
* Changes the server proposes itself, from an attached file or a search result
|
||||
* the user picked rather than from the model. They are held, decided and
|
||||
* recorded like the writes above, and they are not on the allowlist, so no
|
||||
* session ever offers them to the model. A booking is applied by the server
|
||||
* Changes the server applies itself: a booking from an attached file, a search
|
||||
* result the user picked, a note the user wants kept. They are held, decided
|
||||
* and recorded like the writes above, and they are not on the allowlist; only
|
||||
* the note is offered to the model, by the session itself (REMEMBER_SPEC). A booking is applied by the server
|
||||
* itself; a found place deliberately goes through the MCP tool named in its
|
||||
* `via` argument, so it is applied with the user's rights, broadcast and
|
||||
* undone exactly like the same change proposed by the model.
|
||||
@@ -121,6 +130,7 @@ export const ASSISTANT_SERVER_WRITE_TOOLS: Readonly<Record<string, AssistantWrit
|
||||
[IMPORT_BOOKING_TOOL]: { verbKey: 'importBooking', entity: 'reservation' },
|
||||
// The card names the verb of the `via` tool; these are its fallbacks.
|
||||
[ADD_FOUND_PLACE_TOOL]: { verbKey: 'addPlaceToDay', entity: 'place', quiet: true },
|
||||
[REMEMBER_TOOL]: { verbKey: 'rememberNote', entity: 'memory' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { McpRegistryService, type McpRegistryListing } from '../../nest-mcp';
|
||||
import { createLocalMcpServer, McpRegistryService, type McpRegistryListing } from '../../nest-mcp';
|
||||
import type { AiToolSpec } from '../ai-connections/ai-chat-client.interface';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { traceEntry } from '../audit/entry-trace.logger';
|
||||
import { withRequestContext } from '../database/request-context';
|
||||
import { REMEMBER_SPEC } from './assistant-memory';
|
||||
import {
|
||||
ASSISTANT_ARG_POLICY,
|
||||
ASSISTANT_READ_TOOLS,
|
||||
@@ -13,11 +14,11 @@ import {
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
isDeniedTool,
|
||||
PINNED_ARG,
|
||||
REMEMBER_TOOL,
|
||||
} from './assistant-tools.constants';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
type ToolArgs = Record<string, unknown>;
|
||||
@@ -121,23 +122,25 @@ function readResult(result: unknown): ToolRun {
|
||||
*/
|
||||
export class AssistantToolSession {
|
||||
constructor(
|
||||
private readonly client: Client,
|
||||
private readonly server: McpServer,
|
||||
private readonly client: InstanceType<typeof Client>,
|
||||
private readonly server: ReturnType<typeof createLocalMcpServer>,
|
||||
private readonly tools: ReadonlyMap<string, ListedTool>,
|
||||
private readonly tripId: number,
|
||||
private readonly audit: (toolName: string) => void,
|
||||
) {}
|
||||
|
||||
/** The tools as the model is offered them. */
|
||||
/** The tools as the model is offered them, with the trip notes the server keeps itself. */
|
||||
specs(): AiToolSpec[] {
|
||||
return [...this.tools.values()].map((tool) => ({
|
||||
const listed = [...this.tools.values()].map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: modelSchema(tool.name, tool.inputSchema),
|
||||
}));
|
||||
return [...listed, REMEMBER_SPEC];
|
||||
}
|
||||
|
||||
kindOf(name: string): AssistantToolKind | null {
|
||||
if (name === REMEMBER_TOOL) return 'write';
|
||||
return this.tools.get(name)?.kind ?? null;
|
||||
}
|
||||
|
||||
@@ -208,7 +211,7 @@ export class AssistantToolsService implements OnApplicationBootstrap {
|
||||
* attached: the assistant passes no dynamic tool source.
|
||||
*/
|
||||
async open(userId: number, tripId: number, include: ReadonlySet<string> = ASSISTANT_TOOL_ALLOWLIST) {
|
||||
const server = new McpServer({ name: 'TREK assistant', version: '1.0.0' });
|
||||
const server = createLocalMcpServer('TREK assistant');
|
||||
const around = (info: { kind: string; name: string }, call: () => unknown): unknown =>
|
||||
traceEntry('mcp', `${info.kind} ${info.name} user=${userId} client=assistant`, () =>
|
||||
withRequestContext(this.orm, call),
|
||||
|
||||
@@ -25,6 +25,8 @@ How you work:
|
||||
- The trip card may start with what the user is asking about. A question about "this place", "it" or "here" is about that; never ask which place they mean then.
|
||||
- The user can name days, places and bookings with "@" before the name. The trip card then lists what they named, with the ids; use those.
|
||||
- The app shows the places your searches find as cards with a picture, and the weather you look up as a strip. Do not list them again in your text; say in a sentence or two which ones you suggest and why.
|
||||
- When the user asks you to remember something for this trip (who travels, what they like or avoid), keep it with remember_for_trip; it comes back as <trip_notes> in every later conversation. Never say you cannot remember things.
|
||||
- Existing expenses can be changed with update_budget_item (their ids are in get_trip_summary); never add a second entry to correct one.
|
||||
- If the request is unclear, ask one short question instead of guessing.`;
|
||||
|
||||
/** The standing instructions, telling the model which language to answer in. */
|
||||
|
||||
@@ -36,6 +36,7 @@ import { AssistantBookingImportService } from './assistant-booking-import.servic
|
||||
import { mentionItems } from './assistant-cards.helpers';
|
||||
import { buildTripCard, categoriesFrom, languageName, todayIn, turnContext, type TripCard } from './assistant-context';
|
||||
import { messageIdOf, outputText, partsOf, toChatHistory, toThreadMessages } from './assistant-history';
|
||||
import { rememberForTrip } from './assistant-memory';
|
||||
import { toolOutputForProvider } from './assistant-redact';
|
||||
import { AssistantStreamRegistry } from './assistant-stream.registry';
|
||||
import { titleFrom } from './assistant-title';
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
foundPlaceArgs,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
READ_BOOKING_FILE_TOOL,
|
||||
REMEMBER_TOOL,
|
||||
TURNS_PER_MINUTE,
|
||||
UNDO_WINDOW_MS,
|
||||
writeToolOf,
|
||||
@@ -397,6 +399,7 @@ export class AssistantService {
|
||||
): Promise<ToolRun> {
|
||||
if (name === IMPORT_BOOKING_TOOL) return this.inContext(() => this.bookings.apply(userId, tripId, args));
|
||||
if (name === ADD_FOUND_PLACE_TOOL) return session.call(writeToolOf(name, args), foundPlaceArgs(args));
|
||||
if (name === REMEMBER_TOOL) return this.inContext(() => rememberForTrip(this.settings, userId, tripId, args));
|
||||
return session.call(name, args);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { CardPlace, TripCard } from './assistant-context';
|
||||
import { noteOf } from './assistant-memory';
|
||||
import {
|
||||
ASSISTANT_SERVER_WRITE_TOOLS,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
REMEMBER_TOOL,
|
||||
writeToolOf,
|
||||
} from './assistant-tools.constants';
|
||||
import type {
|
||||
@@ -222,8 +224,7 @@ function route(endpoints: unknown): string {
|
||||
if (!Array.isArray(endpoints)) return '';
|
||||
const end = (role: string) => {
|
||||
const found = endpoints.find((ep: unknown) => (ep as { role?: unknown } | null)?.role === role) as
|
||||
| { name?: unknown; code?: unknown }
|
||||
| undefined;
|
||||
{ name?: unknown; code?: unknown } | undefined;
|
||||
const name = text(found?.name);
|
||||
const code = text(found?.code);
|
||||
if (!name) return code;
|
||||
@@ -270,6 +271,13 @@ interface Described {
|
||||
|
||||
type Describer = (ctx: PreviewContext, args: Args) => Described;
|
||||
|
||||
/** Who shares an expense: the uneven split's members, else the equal one's. */
|
||||
function splitOf(args: Args): unknown {
|
||||
return Array.isArray(args.members)
|
||||
? args.members.map((member: unknown) => (member as { user_id?: unknown } | null)?.user_id)
|
||||
: args.member_ids;
|
||||
}
|
||||
|
||||
const DESCRIBERS: Readonly<Record<string, Describer>> = {
|
||||
create_place: (ctx, args) => {
|
||||
const rows = new Rows();
|
||||
@@ -359,9 +367,6 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
|
||||
},
|
||||
create_budget_item: (ctx, args) => {
|
||||
const placeId = id(args, 'place_id');
|
||||
const split = Array.isArray(args.members)
|
||||
? args.members.map((member: unknown) => (member as { user_id?: unknown } | null)?.user_id)
|
||||
: args.member_ids;
|
||||
const rows = new Rows()
|
||||
.add(
|
||||
'price',
|
||||
@@ -370,11 +375,25 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
|
||||
: '',
|
||||
)
|
||||
.add('category', text(args.category))
|
||||
.add('members', memberNames(ctx, split))
|
||||
.add('members', memberNames(ctx, splitOf(args)))
|
||||
.add('location', placeName(ctx, placeId))
|
||||
.add('notes', text(args.note));
|
||||
return { subject: text(args.name), rows, dayId: null, placeIds: placeId ? [placeId] : [] };
|
||||
},
|
||||
update_budget_item: (ctx, args) => {
|
||||
const price =
|
||||
typeof args.total_price === 'number'
|
||||
? money(ctx, args.total_price, text(args.currency) || ctx.card.currency)
|
||||
: '';
|
||||
const rows = new Rows()
|
||||
.add('name', text(args.name))
|
||||
.add('price', price)
|
||||
.add('category', text(args.category))
|
||||
.add('members', memberNames(ctx, splitOf(args)))
|
||||
.add('notes', text(args.note));
|
||||
return { subject: text(args.name) || `#${id(args, 'itemId') ?? ''}`, rows, dayId: null, placeIds: [] };
|
||||
},
|
||||
[REMEMBER_TOOL]: (_ctx, args) => ({ subject: noteOf(args) ?? '', rows: new Rows(), dayId: null, placeIds: [] }),
|
||||
create_reservation: (ctx, args) => {
|
||||
const placeId = id(args, 'place_id');
|
||||
const startDay = id(args, 'start_day_id');
|
||||
|
||||
@@ -236,6 +236,8 @@ describe('platform/discovery parity pins', () => {
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
// A client may name itself by its metadata document's URL (Claude's connector does).
|
||||
client_id_metadata_document_supported: true,
|
||||
userinfo_endpoint: 'https://trek.example.com/oauth/userinfo',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('ModelCatalogService', () => {
|
||||
safeFetch.mockResolvedValueOnce(json(200, { data: [{ id: 'claude-sonnet-5', display_name: 'Claude Sonnet 5' }] }));
|
||||
const key = secret();
|
||||
expect(await catalog().list({ kind: 'anthropic', baseUrl: null, secret: key })).toEqual([
|
||||
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', supportsTools: true },
|
||||
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', supportsTools: true, recommended: true },
|
||||
]);
|
||||
const [url, init] = safeFetch.mock.calls[0] as [string, RequestInit, { maxBytes: number }];
|
||||
expect(url).toBe('https://api.anthropic.com/v1/models?limit=1000');
|
||||
@@ -117,7 +117,7 @@ describe('ModelCatalogService', () => {
|
||||
}),
|
||||
);
|
||||
expect(await catalog().list({ kind: 'openrouter', baseUrl: null, secret: secret() })).toEqual([
|
||||
{ id: 'anthropic/claude-sonnet-5', label: 'Sonnet', supportsTools: true, free: false },
|
||||
{ id: 'anthropic/claude-sonnet-5', label: 'Sonnet', supportsTools: true, free: false, recommended: true },
|
||||
{ id: 'meta/llama:free', label: 'meta/llama:free', supportsTools: true, free: true },
|
||||
]);
|
||||
expect(safeFetch.mock.calls.map((call) => call[0])).toEqual([
|
||||
@@ -167,7 +167,7 @@ describe('ModelCatalogService', () => {
|
||||
}),
|
||||
);
|
||||
expect(await catalog().list({ kind: 'chatgpt', baseUrl: null, secret: secret() })).toEqual([
|
||||
{ id: 'gpt-6.1-sol', label: 'GPT-6.1 Sol', supportsTools: true },
|
||||
{ id: 'gpt-6.1-sol', label: 'GPT-6.1 Sol', supportsTools: true, recommended: true },
|
||||
]);
|
||||
safeFetch.mockResolvedValueOnce(json(500, {}));
|
||||
expect((await catalog().list({ kind: 'chatgpt', baseUrl: null, secret: secret() }))[0]?.id).toBe('gpt-6.1-sol');
|
||||
@@ -183,7 +183,7 @@ describe('ModelCatalogService', () => {
|
||||
it('MCAT-007: a custom server is listed through LlmLocalService, its failure is provider_unavailable', async () => {
|
||||
llmLocal.listModels.mockResolvedValueOnce({ models: [{ name: 'qwen3:14b', size: 9 }] });
|
||||
expect(await catalog().list({ kind: 'custom', baseUrl: 'http://ollama:11434/v1', secret: null })).toEqual([
|
||||
{ id: 'qwen3:14b', label: 'qwen3:14b', supportsTools: true },
|
||||
{ id: 'qwen3:14b', label: 'qwen3:14b', supportsTools: true, recommended: true },
|
||||
]);
|
||||
expect(llmLocal.listModels).toHaveBeenCalledWith('http://ollama:11434/v1', undefined);
|
||||
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'Could not reach local LLM server'));
|
||||
@@ -224,8 +224,9 @@ describe('ModelCatalogService', () => {
|
||||
).toEqual([
|
||||
['gemini-3-flash', true],
|
||||
['gemini-3-pro', false],
|
||||
['gemini-2.5-pro', false],
|
||||
// The same version reads in name order.
|
||||
['gemini-2.5-flash', false],
|
||||
['gemini-2.5-pro', false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
"noEmit": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true
|
||||
}
|
||||
},
|
||||
"include": ["src", "scripts/strict-typings"]
|
||||
}
|
||||
|
||||
@@ -63,8 +63,10 @@ export const ASSISTANT_CHANGE_VERB_KEYS = [
|
||||
'updatePackingItem',
|
||||
'addTodo',
|
||||
'addBudgetItem',
|
||||
'updateBudgetItem',
|
||||
'addReservation',
|
||||
'importBooking',
|
||||
'rememberNote',
|
||||
] as const;
|
||||
export const assistantChangeVerbKeySchema = z.enum(ASSISTANT_CHANGE_VERB_KEYS);
|
||||
export type AssistantChangeVerbKey = z.infer<typeof assistantChangeVerbKeySchema>;
|
||||
@@ -271,7 +273,7 @@ export type AssistantChangePreview = z.infer<typeof assistantChangePreviewSchema
|
||||
/** An applied change: what it touched, and until when it can be undone. */
|
||||
export const assistantAppliedSchema = z.object({
|
||||
toolCallId: z.string(),
|
||||
entity: z.enum(['place', 'assignment', 'day_note', 'packing', 'todo', 'budget', 'reservation']),
|
||||
entity: z.enum(['place', 'assignment', 'day_note', 'packing', 'todo', 'budget', 'reservation', 'memory']),
|
||||
ids: z.array(idSchema),
|
||||
dayId: idSchema.nullable(),
|
||||
undoUntil: z.string().nullable(),
|
||||
|
||||
@@ -488,5 +488,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.showAll.two': 'عرض التغييرين',
|
||||
'assistant.week.showAll.few': 'عرض التغييرات الـ {count}',
|
||||
'assistant.week.showAll.many': 'عرض التغييرات الـ {count}',
|
||||
'assistant.change.verb.updateBudgetItem': 'تعديل المصروف',
|
||||
'assistant.change.verb.rememberNote': 'تذكّر لهذه الرحلة',
|
||||
'assistant.change.details': 'التفاصيل',
|
||||
'assistant.change.detailsHide': 'إخفاء التفاصيل',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -396,5 +396,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} dəyişiklik',
|
||||
'assistant.week.showAll': 'Bütün {count} dəyişikliyi göstər',
|
||||
'assistant.week.showAll.one': '{count} dəyişikliyi göstər',
|
||||
'assistant.change.verb.updateBudgetItem': 'Xərci dəyiş',
|
||||
'assistant.change.verb.rememberNote': 'Bu səyahət üçün yadda saxla',
|
||||
'assistant.change.details': 'Təfərrüatlar',
|
||||
'assistant.change.detailsHide': 'Təfərrüatları gizlət',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -396,5 +396,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': "{count} c'hemm",
|
||||
'assistant.week.showAll': 'Diskouez an {count} kemm',
|
||||
'assistant.week.showAll.one': "Diskouez {count} c'hemm",
|
||||
'assistant.change.verb.updateBudgetItem': 'Kemmañ an dispign',
|
||||
'assistant.change.verb.rememberNote': 'Derc’hel soñj evit ar veaj-mañ',
|
||||
'assistant.change.details': 'Munudoù',
|
||||
'assistant.change.detailsHide': 'Kuzhat ar munudoù',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -399,5 +399,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} canvi',
|
||||
'assistant.week.showAll': 'Mostra els {count} canvis',
|
||||
'assistant.week.showAll.one': 'Mostra {count} canvi',
|
||||
'assistant.change.verb.updateBudgetItem': 'Canvia la despesa',
|
||||
'assistant.change.verb.rememberNote': 'Recorda per a aquest viatge',
|
||||
'assistant.change.details': 'Detalls',
|
||||
'assistant.change.detailsHide': 'Amaga els detalls',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -418,5 +418,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.showAll': 'Zobrazit všech {count} změn',
|
||||
'assistant.week.showAll.one': 'Zobrazit {count} změnu',
|
||||
'assistant.week.showAll.few': 'Zobrazit všechny {count} změny',
|
||||
'assistant.change.verb.updateBudgetItem': 'Změnit výdaj',
|
||||
'assistant.change.verb.rememberNote': 'Zapamatovat pro tuto cestu',
|
||||
'assistant.change.details': 'Podrobnosti',
|
||||
'assistant.change.detailsHide': 'Skrýt podrobnosti',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -396,5 +396,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} Änderung',
|
||||
'assistant.week.showAll': 'Alle {count} Änderungen zeigen',
|
||||
'assistant.week.showAll.one': '{count} Änderung zeigen',
|
||||
'assistant.change.verb.updateBudgetItem': 'Ausgabe ändern',
|
||||
'assistant.change.verb.rememberNote': 'Für diese Reise merken',
|
||||
'assistant.change.details': 'Details',
|
||||
'assistant.change.detailsHide': 'Details ausblenden',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -391,5 +391,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} change',
|
||||
'assistant.week.showAll': 'Show all {count} changes',
|
||||
'assistant.week.showAll.one': 'Show {count} change',
|
||||
'assistant.change.verb.updateBudgetItem': 'Change expense',
|
||||
'assistant.change.verb.rememberNote': 'Remember for this trip',
|
||||
'assistant.change.details': 'Details',
|
||||
'assistant.change.detailsHide': 'Hide details',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -394,5 +394,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} cambio',
|
||||
'assistant.week.showAll': 'Ver los {count} cambios',
|
||||
'assistant.week.showAll.one': 'Ver {count} cambio',
|
||||
'assistant.change.verb.updateBudgetItem': 'Cambiar gasto',
|
||||
'assistant.change.verb.rememberNote': 'Recordar para este viaje',
|
||||
'assistant.change.details': 'Detalles',
|
||||
'assistant.change.detailsHide': 'Ocultar detalles',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -395,5 +395,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} muudatus',
|
||||
'assistant.week.showAll': 'Näita kõiki {count} muudatust',
|
||||
'assistant.week.showAll.one': 'Näita {count} muudatust',
|
||||
'assistant.change.verb.updateBudgetItem': 'Muuda kulu',
|
||||
'assistant.change.verb.rememberNote': 'Jäta selle reisi jaoks meelde',
|
||||
'assistant.change.details': 'Üksikasjad',
|
||||
'assistant.change.detailsHide': 'Peida üksikasjad',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -401,5 +401,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} modification',
|
||||
'assistant.week.showAll': 'Afficher les {count} modifications',
|
||||
'assistant.week.showAll.one': 'Afficher {count} modification',
|
||||
'assistant.change.verb.updateBudgetItem': 'Modifier la dépense',
|
||||
'assistant.change.verb.rememberNote': 'Retenir pour ce voyage',
|
||||
'assistant.change.details': 'Détails',
|
||||
'assistant.change.detailsHide': 'Masquer les détails',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -400,5 +400,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} αλλαγή',
|
||||
'assistant.week.showAll': 'Εμφάνιση όλων των {count} αλλαγών',
|
||||
'assistant.week.showAll.one': 'Εμφάνιση {count} αλλαγής',
|
||||
'assistant.change.verb.updateBudgetItem': 'Αλλαγή εξόδου',
|
||||
'assistant.change.verb.rememberNote': 'Απομνημόνευση για αυτό το ταξίδι',
|
||||
'assistant.change.details': 'Λεπτομέρειες',
|
||||
'assistant.change.detailsHide': 'Απόκρυψη λεπτομερειών',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -401,5 +401,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} módosítás',
|
||||
'assistant.week.showAll': 'Mind a {count} módosítás mutatása',
|
||||
'assistant.week.showAll.one': '{count} módosítás mutatása',
|
||||
'assistant.change.verb.updateBudgetItem': 'Kiadás módosítása',
|
||||
'assistant.change.verb.rememberNote': 'Megjegyezni ehhez az utazáshoz',
|
||||
'assistant.change.details': 'Részletek',
|
||||
'assistant.change.detailsHide': 'Részletek elrejtése',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -373,5 +373,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.check.issues': '{count} hal perlu dicek',
|
||||
'assistant.week.changes': '{count} perubahan',
|
||||
'assistant.week.showAll': 'Tampilkan semua {count} perubahan',
|
||||
'assistant.change.verb.updateBudgetItem': 'Ubah pengeluaran',
|
||||
'assistant.change.verb.rememberNote': 'Ingat untuk perjalanan ini',
|
||||
'assistant.change.details': 'Detail',
|
||||
'assistant.change.detailsHide': 'Sembunyikan detail',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -402,5 +402,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} modifica',
|
||||
'assistant.week.showAll': 'Mostra tutte le {count} modifiche',
|
||||
'assistant.week.showAll.one': 'Mostra {count} modifica',
|
||||
'assistant.change.verb.updateBudgetItem': 'Modifica spesa',
|
||||
'assistant.change.verb.rememberNote': 'Ricorda per questo viaggio',
|
||||
'assistant.change.details': 'Dettagli',
|
||||
'assistant.change.detailsHide': 'Nascondi dettagli',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -368,5 +368,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.check.issues': '確認事項 {count} 件',
|
||||
'assistant.week.changes': '{count} 件の変更',
|
||||
'assistant.week.showAll': '{count} 件の変更をすべて表示',
|
||||
'assistant.change.verb.updateBudgetItem': '支出を変更',
|
||||
'assistant.change.verb.rememberNote': 'この旅行のために覚えておく',
|
||||
'assistant.change.details': '詳細',
|
||||
'assistant.change.detailsHide': '詳細を隠す',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -365,5 +365,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.check.issues': '확인할 점 {count}개',
|
||||
'assistant.week.changes': '변경 {count}개',
|
||||
'assistant.week.showAll': '변경 {count}개 모두 보기',
|
||||
'assistant.change.verb.updateBudgetItem': '지출 변경',
|
||||
'assistant.change.verb.rememberNote': '이 여행에 기억하기',
|
||||
'assistant.change.details': '세부 정보',
|
||||
'assistant.change.detailsHide': '세부 정보 숨기기',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -397,5 +397,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} wijziging',
|
||||
'assistant.week.showAll': 'Alle {count} wijzigingen tonen',
|
||||
'assistant.week.showAll.one': '{count} wijziging tonen',
|
||||
'assistant.change.verb.updateBudgetItem': 'Uitgave wijzigen',
|
||||
'assistant.change.verb.rememberNote': 'Onthouden voor deze reis',
|
||||
'assistant.change.details': 'Details',
|
||||
'assistant.change.detailsHide': 'Details verbergen',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -445,5 +445,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.showAll.one': 'Pokaż {count} zmianę',
|
||||
'assistant.week.showAll.few': 'Pokaż wszystkie {count} zmiany',
|
||||
'assistant.week.showAll.many': 'Pokaż wszystkie {count} zmian',
|
||||
'assistant.change.verb.updateBudgetItem': 'Zmień wydatek',
|
||||
'assistant.change.verb.rememberNote': 'Zapamiętaj dla tej podróży',
|
||||
'assistant.change.details': 'Szczegóły',
|
||||
'assistant.change.detailsHide': 'Ukryj szczegóły',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -445,5 +445,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.showAll.one': 'Показать {count} изменение',
|
||||
'assistant.week.showAll.few': 'Показать все {count} изменения',
|
||||
'assistant.week.showAll.many': 'Показать все {count} изменений',
|
||||
'assistant.change.verb.updateBudgetItem': 'Изменить расход',
|
||||
'assistant.change.verb.rememberNote': 'Запомнить для этой поездки',
|
||||
'assistant.change.details': 'Подробности',
|
||||
'assistant.change.detailsHide': 'Скрыть подробности',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -418,5 +418,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.showAll': 'Zobraziť všetkých {count} zmien',
|
||||
'assistant.week.showAll.one': 'Zobraziť {count} zmenu',
|
||||
'assistant.week.showAll.few': 'Zobraziť všetky {count} zmeny',
|
||||
'assistant.change.verb.updateBudgetItem': 'Zmeniť výdavok',
|
||||
'assistant.change.verb.rememberNote': 'Zapamätať pre túto cestu',
|
||||
'assistant.change.details': 'Podrobnosti',
|
||||
'assistant.change.detailsHide': 'Skryť podrobnosti',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -395,5 +395,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} ändring',
|
||||
'assistant.week.showAll': 'Visa alla {count} ändringar',
|
||||
'assistant.week.showAll.one': 'Visa {count} ändring',
|
||||
'assistant.change.verb.updateBudgetItem': 'Ändra utgift',
|
||||
'assistant.change.verb.rememberNote': 'Kom ihåg för den här resan',
|
||||
'assistant.change.details': 'Detaljer',
|
||||
'assistant.change.detailsHide': 'Dölj detaljer',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -365,5 +365,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.check.issues': 'มี {count} จุดที่ควรตรวจ',
|
||||
'assistant.week.changes': 'การเปลี่ยนแปลง {count} รายการ',
|
||||
'assistant.week.showAll': 'แสดงการเปลี่ยนแปลงทั้ง {count} รายการ',
|
||||
'assistant.change.verb.updateBudgetItem': 'แก้ไขค่าใช้จ่าย',
|
||||
'assistant.change.verb.rememberNote': 'จำไว้สำหรับทริปนี้',
|
||||
'assistant.change.details': 'รายละเอียด',
|
||||
'assistant.change.detailsHide': 'ซ่อนรายละเอียด',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -395,5 +395,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.changes.one': '{count} değişiklik',
|
||||
'assistant.week.showAll': '{count} değişikliğin tümünü göster',
|
||||
'assistant.week.showAll.one': '{count} değişikliği göster',
|
||||
'assistant.change.verb.updateBudgetItem': 'Harcamayı değiştir',
|
||||
'assistant.change.verb.rememberNote': 'Bu gezi için hatırla',
|
||||
'assistant.change.details': 'Ayrıntılar',
|
||||
'assistant.change.detailsHide': 'Ayrıntıları gizle',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -443,5 +443,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.week.showAll.one': 'Показати {count} зміну',
|
||||
'assistant.week.showAll.few': 'Показати всі {count} зміни',
|
||||
'assistant.week.showAll.many': 'Показати всі {count} змін',
|
||||
'assistant.change.verb.updateBudgetItem': 'Змінити витрату',
|
||||
'assistant.change.verb.rememberNote': 'Запам’ятати для цієї подорожі',
|
||||
'assistant.change.details': 'Подробиці',
|
||||
'assistant.change.detailsHide': 'Сховати подробиці',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -368,5 +368,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.check.issues': '{count} điểm cần xem',
|
||||
'assistant.week.changes': '{count} thay đổi',
|
||||
'assistant.week.showAll': 'Hiện tất cả {count} thay đổi',
|
||||
'assistant.change.verb.updateBudgetItem': 'Sửa khoản chi',
|
||||
'assistant.change.verb.rememberNote': 'Ghi nhớ cho chuyến đi này',
|
||||
'assistant.change.details': 'Chi tiết',
|
||||
'assistant.change.detailsHide': 'Ẩn chi tiết',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -362,5 +362,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.check.issues': '{count} 處需要注意',
|
||||
'assistant.week.changes': '{count} 項變更',
|
||||
'assistant.week.showAll': '顯示全部 {count} 項變更',
|
||||
'assistant.change.verb.updateBudgetItem': '修改支出',
|
||||
'assistant.change.verb.rememberNote': '為此行程記住',
|
||||
'assistant.change.details': '詳細資料',
|
||||
'assistant.change.detailsHide': '隱藏詳細資料',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -362,5 +362,9 @@ const assistant: TranslationStrings = {
|
||||
'assistant.check.issues': '{count} 处需要注意',
|
||||
'assistant.week.changes': '{count} 项更改',
|
||||
'assistant.week.showAll': '显示全部 {count} 项更改',
|
||||
'assistant.change.verb.updateBudgetItem': '修改支出',
|
||||
'assistant.change.verb.rememberNote': '为此行程记住',
|
||||
'assistant.change.details': '详情',
|
||||
'assistant.change.detailsHide': '隐藏详情',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -5,15 +5,7 @@
|
||||
*/
|
||||
|
||||
export type PlaceKind =
|
||||
| 'lodging'
|
||||
| 'food'
|
||||
| 'drinks'
|
||||
| 'sight'
|
||||
| 'shopping'
|
||||
| 'transport'
|
||||
| 'beach'
|
||||
| 'nature'
|
||||
| 'activity';
|
||||
'lodging' | 'food' | 'drinks' | 'sight' | 'shopping' | 'transport' | 'beach' | 'nature' | 'activity';
|
||||
|
||||
/** A trip category as far as the guess reads it. */
|
||||
export interface PlaceKindCategory {
|
||||
|
||||
Reference in New Issue
Block a user