mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(assistant): one AI tile for assistant, parsing and MCP, a planner-aware chat
The three AI addons share one tile in the integration column; a dialog holds the connection, the assistant's limits and AI parsing, which now reads with the assistant's connection unless an install keeps its own. Existing parsing configs stay where they are and keep winning. The chat sees the map on screen, offers next questions under an answer, shows its live step in the window head, previews waiting changes inside the day list, checks whether a day still works, folds a multi-day plan into a week grid, keeps per-trip notes and can estimate costs. An empty or cut-off answer gets one push instead of ending silently, and the assistant's own step and size limits are raised.
This commit is contained in:
@@ -16,17 +16,16 @@
|
||||
"feature": "Addon manager",
|
||||
"hooks": [
|
||||
"src/components/Admin/addons/addonModel.ts",
|
||||
"src/components/Admin/addons/useAddonManager.ts",
|
||||
"src/components/Admin/addons/useLlmParsingConfig.ts"
|
||||
"src/components/Admin/addons/useAddonManager.ts"
|
||||
],
|
||||
"desktop": ["src/components/Admin/AddonManager.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Addon manager: AI assistant",
|
||||
"hooks": ["src/components/Admin/addons/useAiAdminConfig.ts"],
|
||||
"desktop": ["src/components/Admin/addons/AiAdminConfig.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAiConfig.tsx"]
|
||||
"feature": "Addon manager: AI tile",
|
||||
"hooks": ["src/components/Admin/addons/useAiAddonTile.ts"],
|
||||
"desktop": ["src/components/Admin/addons/AiAddonTile.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin notification preference matrix",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -24,23 +24,20 @@ function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
|
||||
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
||||
}
|
||||
|
||||
function llmAddon(config: Record<string, unknown> = {}) {
|
||||
function llmAddon(config: Record<string, unknown> = {}, enabled = true) {
|
||||
return buildAddon({
|
||||
id: 'llm_parsing',
|
||||
name: 'AI Parsing',
|
||||
description: 'Extract bookings from files',
|
||||
icon: 'Sparkles',
|
||||
type: 'integration',
|
||||
enabled: true,
|
||||
enabled,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
|
||||
});
|
||||
function aiAddon(id: string, name: string, enabled: boolean) {
|
||||
return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled });
|
||||
}
|
||||
|
||||
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
||||
@@ -576,286 +573,55 @@ describe('AddonManager', () => {
|
||||
expect(links.getAttribute('title')).not.toBe('No shared links yet');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
|
||||
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
// The catalog key now exists, so the tile shows the translation rather than falling
|
||||
// back to the English description the server sent.
|
||||
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
const label = await screen.findByText('Provider');
|
||||
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
|
||||
const tile = label.closest('article');
|
||||
expect(tile).not.toBeNull();
|
||||
expect(tile!.textContent).toContain('AI Parsing');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
await screen.findByRole('button', { name: 'llama3:8b' });
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
||||
|
||||
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
it('FE-ADMIN-ADDON-020: the AI addons head the integration column as one tile with a switch each', async () => {
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
||||
: HttpResponse.json({ models: [] });
|
||||
}),
|
||||
addonsRoute([
|
||||
aiAddon('mcp', 'MCP', false),
|
||||
llmAddon({}, false),
|
||||
aiAddon('ai_assistant', 'AI Assistant', false),
|
||||
buildAddon({ id: 'airtrail', name: 'AirTrail', type: 'integration', icon: 'Plane' }),
|
||||
]),
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
// The server's reason is shown, not axios' status line (#2649)
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||
const switches = within(tile).getAllByRole('button').map((b) => b.getAttribute('aria-label'));
|
||||
// In the tile's order, whatever order the server sent them in.
|
||||
expect(switches).toEqual(['AI Assistant', 'AI Parsing', 'MCP']);
|
||||
expect(within(tile).getByText('0/3')).toBeInTheDocument();
|
||||
// Their own icons, keyed by id: the icon column of an install still says Sparkles or Terminal.
|
||||
expect(tile.querySelector('svg.lucide-messages-square')).toBeInTheDocument();
|
||||
expect(tile.querySelector('svg.lucide-scan-text')).toBeInTheDocument();
|
||||
expect(tile.querySelector('svg.lucide-cable')).toBeInTheDocument();
|
||||
// Nothing to set up while neither the assistant nor parsing is on.
|
||||
expect(within(tile).queryByRole('button', { name: 'Set up AI' })).toBeNull();
|
||||
// AirTrail is still its own tile in the same column.
|
||||
expect(screen.getByText('AirTrail').closest('article')).not.toBe(tile);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
|
||||
it('FE-ADMIN-ADDON-021: with parsing on, the tile offers the settings dialog, which closes again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
|
||||
server.use(
|
||||
addonsRoute([llmAddon({}), aiAddon('ai_assistant', 'AI Assistant', false), aiAddon('mcp', 'MCP', true)]),
|
||||
http.get('/api/admin/ai/settings', () =>
|
||||
HttpResponse.json({ policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }),
|
||||
),
|
||||
http.get('/api/admin/ai/connection', () => HttpResponse.json({ connection: null, prefill: null })),
|
||||
http.get('/api/ai/status', () => HttpResponse.json({ error: 'not needed' }, { status: 404 })),
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
||||
|
||||
// A hand-typed base URL is used for the next lookup on blur
|
||||
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||
expect(within(tile).getByText('2/3')).toBeInTheDocument();
|
||||
await user.click(within(tile).getByRole('button', { name: 'Set up AI' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole('button', { name: 'AI Assistant' })).toBeNull();
|
||||
const closes = within(dialog).getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closes[closes.length - 1]);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
|
||||
const user = userEvent.setup();
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('Pulling…');
|
||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
await screen.findByText('no disk space');
|
||||
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Failed to save');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
|
||||
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The model is trimmed before it is stored, the key is sent verbatim
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
|
||||
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-038: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
// Stored off stays off; a cloud provider is told what Automatic means for it.
|
||||
await user.click(await screen.findByRole('button', { name: 'No' }));
|
||||
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } });
|
||||
expect(bodies[0]).not.toHaveProperty('config.multimodal');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
|
||||
render(<AddonManager />);
|
||||
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
)),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Provider');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
||||
expect(urls).toHaveLength(0);
|
||||
});
|
||||
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
@@ -901,6 +667,10 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
// Folded like Collab's and Documents' options until the bar is opened.
|
||||
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||
await unfoldShelves();
|
||||
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
||||
expect(isOn(toggle)).toBe(false);
|
||||
await userEvent.click(toggle);
|
||||
@@ -921,6 +691,7 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
await unfoldShelves();
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
||||
|
||||
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useId, type ComponentType } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
@@ -12,12 +10,10 @@ import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
|
||||
import { useAddonManager } from './addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
|
||||
import AiAdminConfig from './addons/AiAdminConfig'
|
||||
import AiAddonTile from './addons/AiAddonTile'
|
||||
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
||||
import WandererMark from '../Tours/wanderer/WandererMark'
|
||||
|
||||
@@ -75,7 +71,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const { t } = useTranslation()
|
||||
const dark = useIsDark()
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
@@ -155,37 +151,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
})
|
||||
}
|
||||
// An array, not a fragment: the tile counts its rows to fold them behind the
|
||||
// "N options" bar the way Collab's and Documents' are.
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return (
|
||||
<>
|
||||
return [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>,
|
||||
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
key={key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(titleKey)}
|
||||
description={t(subtitleKey)}
|
||||
enabled={enabled}
|
||||
onToggle={() => void toggle()}
|
||||
/>
|
||||
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
key={key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(titleKey)}
|
||||
description={t(subtitleKey)}
|
||||
enabled={enabled}
|
||||
onToggle={() => void toggle()}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
// just as a form instead of toggle rows.
|
||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
||||
return <LlmParsingConfig addon={addon} />
|
||||
}
|
||||
// The assistant's settings and the connection everyone runs on, the same way.
|
||||
if (addon.id === 'ai_assistant' && addon.enabled) {
|
||||
return <AiAdminConfig />
|
||||
)),
|
||||
]
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -214,9 +202,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
{ key: 'integration', addons: integrationAddons },
|
||||
].filter(g => g.addons.length > 0)
|
||||
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
|
||||
|
||||
const subtitle = (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
@@ -247,8 +235,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
|
||||
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
{g.addons.map(tile)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
@@ -287,140 +279,3 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
|
||||
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
|
||||
} = useLlmParsingConfig(addon)
|
||||
const fieldId = useId()
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so it is one column of
|
||||
eyebrow fields: the band this used to be had a whole page width. */
|
||||
return (
|
||||
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<SettingsHint>
|
||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
||||
</SettingsHint>
|
||||
|
||||
<EditorField label="Provider">
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</EditorField>
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label="API key"
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
|
||||
>
|
||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</EditorField>
|
||||
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
|
||||
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</EditorField>
|
||||
|
||||
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={vision}
|
||||
onChange={v => setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
aria-pressed={model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
|
||||
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ChevronRight, Settings2, Sparkles } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { StatusPill } from '../../Settings/settingsKit';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import AddonSubRow from '../AddonSubRow';
|
||||
import { type Addon, getAddonLabel } from './addonModel';
|
||||
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
|
||||
import AiSettingsDialog from './AiAdminConfig';
|
||||
import { useAiAddonTile } from './useAiAddonTile';
|
||||
|
||||
/**
|
||||
* The AI addons as one tile at the head of the integration column: the
|
||||
* assistant, AI parsing and MCP as switches in its shelf, and one button to
|
||||
* the dialog that holds their settings, so a page with all three on stays as
|
||||
* short as one with none.
|
||||
*/
|
||||
export default function AiAddonTile({
|
||||
addons,
|
||||
onToggle,
|
||||
}: {
|
||||
addons: Addon[];
|
||||
onToggle: (addon: Addon) => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const tile = useAiAddonTile(addons);
|
||||
const anyOn = tile.enabledCount > 0;
|
||||
return (
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
|
||||
anyOn
|
||||
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
|
||||
: 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{t('admin.addons.ai.name')}
|
||||
</h4>
|
||||
<p
|
||||
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('admin.addons.ai.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-none pt-1">
|
||||
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
|
||||
{tile.enabledCount}/{addons.length}
|
||||
</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
|
||||
{addons.map((addon) => {
|
||||
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
|
||||
const label = getAddonLabel(t, addon);
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={addon.id}
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{tile.canSetUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tile.openDialog}
|
||||
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||
<Settings2 size={14} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.addons.ai.setup')}
|
||||
</span>
|
||||
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-004: the AI assistant's form on its addon tile.
|
||||
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { ToastContainer } from '../../shared/Toast';
|
||||
import type { Addon } from './addonModel';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
@@ -16,7 +20,7 @@ const repo = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
|
||||
const { default: AiAdminConfig } = await import('./AiAdminConfig');
|
||||
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
@@ -32,12 +36,44 @@ const LIVE: AiAdminConnectionResponse = {
|
||||
prefill: null,
|
||||
};
|
||||
|
||||
function renderConfig() {
|
||||
return render(
|
||||
<ul>
|
||||
<AiAdminConfig />
|
||||
</ul>
|
||||
function addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
|
||||
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
|
||||
}
|
||||
|
||||
const ASSISTANT = addon('ai_assistant', true);
|
||||
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
|
||||
|
||||
function renderDialog(addons: Addon[] = [ASSISTANT]) {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AiSettingsDialog addons={addons} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
/** The dialog opened on AI parsing's tab. */
|
||||
async function renderParsing(config: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup();
|
||||
renderDialog([ASSISTANT, parsing(config)]);
|
||||
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
|
||||
return user;
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
|
||||
});
|
||||
}
|
||||
|
||||
function saveRoute(bodies: unknown[], fail = false) {
|
||||
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -51,32 +87,35 @@ beforeEach(() => {
|
||||
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
|
||||
});
|
||||
|
||||
describe('AiAdminConfig', () => {
|
||||
it('FE-COMP-AI-ADMIN-001: shows who pays, the setup for everyone, the daily limit and the retention', async () => {
|
||||
renderConfig();
|
||||
describe('the connection and the assistant', () => {
|
||||
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Sign in with ChatGPT')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
|
||||
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfig();
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
|
||||
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
|
||||
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
renderConfig();
|
||||
renderDialog();
|
||||
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
|
||||
expect(screen.getByText('…9f3c')).toBeInTheDocument();
|
||||
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
|
||||
@@ -89,7 +128,7 @@ describe('AiAdminConfig', () => {
|
||||
it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null });
|
||||
renderConfig();
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
|
||||
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
|
||||
@@ -97,4 +136,201 @@ describe('AiAdminConfig', () => {
|
||||
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
|
||||
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
|
||||
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
|
||||
const closes = screen.getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closes[closes.length - 1]);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI parsing', () => {
|
||||
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
server.use(modelsRoute([]));
|
||||
const user = await renderParsing({ vision: 'auto' });
|
||||
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
|
||||
expect(screen.getByLabelText('Model')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
|
||||
const user = await renderParsing();
|
||||
expect(
|
||||
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Connection' }));
|
||||
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
|
||||
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByText('claude-x')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
|
||||
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
const user = await renderParsing({ provider: 'local', model: 'old' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
|
||||
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
|
||||
})
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
await user.clear(screen.getByLabelText('Model'));
|
||||
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
|
||||
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
// The stale local address must not ride along to Anthropic, and the model is trimmed.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(modelsRoute([], urls));
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
const before = urls.length;
|
||||
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
expect(urls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } }
|
||||
);
|
||||
})
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
|
||||
server.use(
|
||||
modelsRoute([]),
|
||||
http.post(
|
||||
'/api/admin/llm/local/pull',
|
||||
() =>
|
||||
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
|
||||
headers: { 'Content-Type': 'application/x-ndjson' },
|
||||
})
|
||||
)
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
||||
});
|
||||
server.use(saveRoute(bodies, true));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Could not save AI settings');
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(saveRoute(bodies));
|
||||
const user = await renderParsing({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1-mini',
|
||||
baseUrl: '',
|
||||
apiKey: 'sk',
|
||||
vision: 'off',
|
||||
});
|
||||
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
import React, { useId } from 'react';
|
||||
import { Check, Loader2, Sparkles } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell';
|
||||
import { NumericInput } from '../../shared/NumericInput';
|
||||
import { EditorField, INPUT } from '../../shared/dialogParts';
|
||||
import { EditorField, INPUT, Segmented } from '../../shared/dialogParts';
|
||||
import ParsingSettings from './ParsingSettings';
|
||||
import type { Addon } from './addonModel';
|
||||
import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig';
|
||||
|
||||
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||
@@ -125,28 +136,12 @@ function InstanceConnection({ A }: { A: AiAdmin }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The AI assistant's settings on its addon tile, as a form in the shelf like
|
||||
* AI parsing's: who pays, the connection for everyone, the daily limit while
|
||||
* the instance pays, and how long chats are kept. Each change saves at once.
|
||||
*/
|
||||
export default function AiAdminConfig(): React.ReactElement {
|
||||
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
|
||||
function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const A = useAiAdminConfig();
|
||||
const fieldId = useId();
|
||||
|
||||
if (!A.loaded) {
|
||||
return (
|
||||
<li className="grid place-items-center p-4">
|
||||
<Loader2 size={18} className="animate-spin text-content-faint" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-4 p-3">
|
||||
<PolicyChoice A={A} />
|
||||
{A.instancePays && <InstanceConnection A={A} />}
|
||||
<div className="flex flex-col gap-4">
|
||||
{A.instancePays && (
|
||||
<EditorField
|
||||
label={t('assistant.admin.dailyLimit')}
|
||||
@@ -175,6 +170,91 @@ export default function AiAdminConfig(): React.ReactElement {
|
||||
size="sm"
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AiTab = 'connection' | 'assistant' | 'parsing';
|
||||
|
||||
/**
|
||||
* The AI settings behind the AI tile's button, in one dialog so the addon page
|
||||
* stays short: the connection the assistant and AI parsing share (and who pays
|
||||
* for it), the assistant's limits, and how bookings are read. A tab shows only
|
||||
* while its addon is on. Each change saves at once.
|
||||
*/
|
||||
export default function AiSettingsDialog({
|
||||
addons,
|
||||
onClose,
|
||||
}: {
|
||||
addons: Addon[];
|
||||
onClose: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const A = useAiAdminConfig();
|
||||
const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled);
|
||||
const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null;
|
||||
const tabs: { value: AiTab; label: string }[] = [
|
||||
{ value: 'connection', label: t('admin.addons.ai.tab.connection') },
|
||||
...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []),
|
||||
...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []),
|
||||
];
|
||||
const [tab, setTab] = useState<AiTab>('connection');
|
||||
const shown = tabs.some((x) => x.value === tab) ? tab : 'connection';
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="detail"
|
||||
align="top"
|
||||
blocked={A.instance.confirmingRemove}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Sparkles size={20} strokeWidth={2} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
eyebrow={t('admin.addons.ai.name')}
|
||||
title={t('admin.addons.ai.setup')}
|
||||
sub={t('admin.addons.ai.dialogSub')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
{tabs.length > 1 && (
|
||||
<Segmented<AiTab> label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill />
|
||||
)}
|
||||
{!A.loaded ? (
|
||||
<div className="grid place-items-center py-10">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{shown === 'connection' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PolicyChoice A={A} />
|
||||
{A.instancePays && <InstanceConnection A={A} />}
|
||||
</div>
|
||||
)}
|
||||
{shown === 'assistant' && <AssistantLimits A={A} />}
|
||||
{shown === 'parsing' && parsing && (
|
||||
<ParsingSettings addon={parsing} A={A} onOpenConnection={() => setTab('connection')} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={A.instance.confirmingRemove}
|
||||
@@ -185,6 +265,6 @@ export default function AiAdminConfig(): React.ReactElement {
|
||||
confirmLabel={t('assistant.admin.connectionRemove')}
|
||||
danger
|
||||
/>
|
||||
</li>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
|
||||
import { Check, RefreshCw } from 'lucide-react';
|
||||
import React, { useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { DialogSection, fs } from '../../shared/DialogShell';
|
||||
import { EditorField, INPUT, PANEL, Segmented } from '../../shared/dialogParts';
|
||||
import { type Addon, ANTHROPIC_HINT, MASKED, RECOMMENDED_MODELS } from './addonModel';
|
||||
import type { useAiAdminConfig } from './useAiAdminConfig';
|
||||
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||
|
||||
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||
type Parsing = ReturnType<typeof useLlmParsingConfig>;
|
||||
type MarkKind = Parameters<typeof ProviderMark>[0]['kind'];
|
||||
|
||||
/** Parsing's own providers, with the mark the assistant shows for each. */
|
||||
const OWN_PROVIDERS = [
|
||||
{ value: 'local', labelKey: 'settings.aiParsing.providerLocal', mark: 'custom' },
|
||||
{ value: 'openai', labelKey: 'settings.aiParsing.providerOpenai', mark: 'openai' },
|
||||
{ value: 'anthropic', labelKey: 'settings.aiParsing.providerAnthropic', mark: 'anthropic' },
|
||||
] as const;
|
||||
|
||||
const markOf = (provider: string): MarkKind => OWN_PROVIDERS.find((p) => p.value === provider)?.mark ?? 'custom';
|
||||
|
||||
/** The connection parsing reads with, as one line: a mark, who answers, the model and where it comes from. */
|
||||
function ReadsWith({ mark, title, model, tag }: { mark: MarkKind; title: string; model: string; tag: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<ProviderMark kind={mark} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
|
||||
{model}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill>{tag}</StatusPill>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Which connection parsing reads with now, and the way to the other one. */
|
||||
function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onOpenConnection: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const live = A.instancePays ? A.instance.live : null;
|
||||
if (P.ownConnection) {
|
||||
const own = OWN_PROVIDERS.find((p) => p.value === P.savedProvider);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ReadsWith
|
||||
mark={markOf(P.savedProvider)}
|
||||
title={own ? t(own.labelKey) : P.savedProvider}
|
||||
model={P.savedModel}
|
||||
tag={t('admin.addons.ai.parsing.own')}
|
||||
/>
|
||||
<SettingsHint>{t('admin.addons.ai.parsing.ownHint')}</SettingsHint>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.switchToShared()}
|
||||
disabled={P.saving}
|
||||
className={`${SETTINGS_BUTTON} self-start`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.addons.ai.parsing.useShared')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{live ? (
|
||||
<ReadsWith
|
||||
mark={live.kind}
|
||||
title={A.instance.provider}
|
||||
model={live.model}
|
||||
tag={t('admin.addons.ai.parsing.sharedTag')}
|
||||
/>
|
||||
) : (
|
||||
<SettingsHint>
|
||||
{t(A.policy === 'user' ? 'admin.addons.ai.parsing.perUser' : 'admin.addons.ai.parsing.none')}
|
||||
</SettingsHint>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
|
||||
{!live && A.policy !== 'user' && (
|
||||
<button type="button" onClick={onOpenConnection} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{t('admin.addons.ai.tab.connection')}
|
||||
</button>
|
||||
)}
|
||||
{!P.formOpen && (
|
||||
<button type="button" onClick={P.startOwn} className={SETTINGS_BUTTON}>
|
||||
{t('admin.addons.ai.parsing.setupOwn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The models on the local server to pick from, and the recommended ones to pull. */
|
||||
function LocalModels({ P }: { P: Parsing }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={PANEL}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{t('admin.addons.ai.parsing.installed')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.loadModels()}
|
||||
disabled={P.loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-card hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={P.loadingModels ? 'animate-spin' : undefined} />
|
||||
{t('admin.addons.ai.parsing.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
{P.modelsErr && (
|
||||
<p className="m-0 break-words text-danger" style={fs(11.5)}>
|
||||
{P.modelsErr}
|
||||
</p>
|
||||
)}
|
||||
{!P.modelsErr && P.installed.length === 0 && !P.loadingModels && (
|
||||
<SettingsHint>{t('admin.addons.ai.parsing.noModels')}</SettingsHint>
|
||||
)}
|
||||
{P.installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{P.installed.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => P.setModel(name)}
|
||||
aria-pressed={P.model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${P.model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
|
||||
{t('admin.addons.ai.parsing.pullTitle')}
|
||||
</span>
|
||||
{RECOMMENDED_MODELS.map((m) => {
|
||||
const here = P.isInstalled(m.id);
|
||||
const chosen = P.model === m.id;
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.recommended && <StatusPill tone="success">{t('assistant.model.recommended')}</StatusPill>}
|
||||
{here ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => P.setModel(m.id)}
|
||||
disabled={chosen}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
|
||||
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.pull(m.id)}
|
||||
disabled={!!P.pulling}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{P.pulling === m.id && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-200"
|
||||
style={{ width: `${P.pullPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{P.pullStatus}
|
||||
{P.pullPct ? ` ${P.pullPct}%` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A connection of parsing's own: the provider as tiles, its address, key and model. */
|
||||
function OwnConnectionForm({ P }: { P: Parsing }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
return (
|
||||
<div className={`flex flex-col gap-3 ${P.saving ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.aiParsing.provider')}
|
||||
className="grid grid-cols-3 gap-2 max-sm:grid-cols-1"
|
||||
>
|
||||
{OWN_PROVIDERS.map((p) => {
|
||||
const on = P.provider === p.value;
|
||||
return (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
onClick={() => P.setProvider(p.value)}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-[12px] border p-2.5 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
|
||||
>
|
||||
<ProviderMark kind={p.mark} size="sm" />
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate font-medium ${on ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(p.labelKey)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<EditorField
|
||||
label={t('settings.aiParsing.baseUrl')}
|
||||
htmlFor={`${fieldId}-url`}
|
||||
hint={t('settings.aiParsing.baseUrlHint')}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-url`}
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
className={INPUT}
|
||||
value={P.baseUrl}
|
||||
onChange={(e) => P.setBaseUrl(e.target.value)}
|
||||
onBlur={() => void P.loadModels()}
|
||||
placeholder={P.baseUrlPlaceholder}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label={t('settings.aiParsing.apiKey')}
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={P.provider === 'anthropic' ? ANTHROPIC_HINT : t('settings.aiParsing.apiKeyHint')}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-key`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className={INPUT}
|
||||
value={P.apiKey}
|
||||
onChange={(e) => P.setApiKey(e.target.value)}
|
||||
placeholder={
|
||||
P.apiKey === MASKED ? MASKED : P.provider === 'local' ? t('admin.addons.ai.parsing.apiKeyOptional') : ''
|
||||
}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('settings.aiParsing.model')} htmlFor={`${fieldId}-model`}>
|
||||
<input
|
||||
id={`${fieldId}-model`}
|
||||
autoComplete="off"
|
||||
className={`${INPUT} font-geist`}
|
||||
value={P.model}
|
||||
onChange={(e) => P.setModel(e.target.value)}
|
||||
/>
|
||||
</EditorField>
|
||||
{P.provider === 'local' && <LocalModels P={P} />}
|
||||
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
|
||||
{!P.ownConnection && (
|
||||
<button type="button" onClick={P.cancelOwn} disabled={P.saving} className={SETTINGS_BUTTON}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.save()}
|
||||
disabled={P.saving || !P.model.trim()}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{t(P.saving ? 'common.saving' : 'common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI parsing in the AI dialog: which connection reads the bookings (the shared
|
||||
* one, or one of its own an older install kept), a connection of its own when
|
||||
* wanted, and whether the model reads images. Each part saves on its own.
|
||||
*/
|
||||
export default function ParsingSettings({
|
||||
addon,
|
||||
A,
|
||||
onOpenConnection,
|
||||
}: {
|
||||
addon: Addon;
|
||||
A: AiAdmin;
|
||||
onOpenConnection: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const P = useLlmParsingConfig(addon);
|
||||
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
|
||||
const local =
|
||||
(P.ownConnection ? P.savedProvider : A.instance.live?.kind === 'custom' ? 'local' : 'cloud') === 'local';
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<DialogSection label={t('admin.addons.ai.parsing.readsWith')}>
|
||||
<ParsingSource A={A} P={P} onOpenConnection={onOpenConnection} />
|
||||
</DialogSection>
|
||||
{P.formOpen && (
|
||||
<DialogSection label={t('admin.addons.ai.parsing.own')}>
|
||||
<OwnConnectionForm P={P} />
|
||||
</DialogSection>
|
||||
)}
|
||||
<DialogSection label={t('settings.aiParsing.multimodal')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={P.vision}
|
||||
onChange={(v) => P.setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
<div className="mt-1.5">
|
||||
<SettingsHint>
|
||||
{t(local ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
|
||||
</SettingsHint>
|
||||
</div>
|
||||
</DialogSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react';
|
||||
import type { Addon } from './addonModel';
|
||||
|
||||
/**
|
||||
* The three addons that talk to a language model, shown as one AI tile in the
|
||||
* integration column: the assistant in the trip, AI parsing (booking import)
|
||||
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
|
||||
* own; only the page groups them. Assistant and parsing share one connection.
|
||||
*/
|
||||
export const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
|
||||
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
|
||||
|
||||
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
|
||||
export const AI_ADDON_ICONS: Record<AiAddonId, LucideIcon> = {
|
||||
ai_assistant: MessagesSquare,
|
||||
llm_parsing: ScanText,
|
||||
mcp: Cable,
|
||||
};
|
||||
|
||||
export function isAiAddon(addon: Addon): boolean {
|
||||
return (AI_ADDON_IDS as readonly string[]).includes(addon.id);
|
||||
}
|
||||
|
||||
/** The AI addons this install has, in the tile's order. */
|
||||
export function aiAddonsOf(addons: Addon[]): Addon[] {
|
||||
return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []);
|
||||
}
|
||||
|
||||
/** Whether the shared connection is used at all: the assistant or AI parsing is on. */
|
||||
export function usesConnection(addons: Addon[]): boolean {
|
||||
return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ const ADDONS = [
|
||||
addon('llm_parsing', 'integration', false),
|
||||
addon('immich', 'photo_provider', false),
|
||||
addon('gdrive', 'document_provider'),
|
||||
addon('airtrail', 'integration', false),
|
||||
addon('mcp', 'integration'),
|
||||
];
|
||||
|
||||
let loadAddons: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
@@ -60,14 +62,16 @@ function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' |
|
||||
}
|
||||
|
||||
describe('useAddonManager', () => {
|
||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
|
||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration, AI and the two shelves', async () => {
|
||||
const { result, rerender } = await loaded();
|
||||
rerender();
|
||||
expect(adminApi.addons).toHaveBeenCalledTimes(1);
|
||||
const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key);
|
||||
expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']);
|
||||
expect(ids(result.current.globalAddons)).toEqual(['journey']);
|
||||
expect(ids(result.current.integrationAddons)).toEqual(['llm_parsing']);
|
||||
// The AI addons leave the integration list for their own tile, in its order.
|
||||
expect(ids(result.current.integrationAddons)).toEqual(['airtrail']);
|
||||
expect(ids(result.current.aiAddons)).toEqual(['llm_parsing', 'mcp']);
|
||||
expect(result.current.providerOptions).toEqual([
|
||||
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||
import { aiAddonsOf, isAiAddon } from './aiAddons';
|
||||
|
||||
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
|
||||
function loadToursFlag(
|
||||
@@ -139,7 +140,9 @@ export function useAddonManager() {
|
||||
|
||||
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
|
||||
const globalAddons = addons.filter((a) => a.type === 'global');
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration');
|
||||
// The AI addons leave the list to sit together on one tile at its head.
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration' && !isAiAddon(a));
|
||||
const aiAddons = aiAddonsOf(addons);
|
||||
const providerOptions = addons.filter((a) => a.type === 'photo_provider').map(asOption);
|
||||
// No credential form under these, unlike the photo providers: a document connection
|
||||
// belongs to a trip and is entered there. The admin only decides whether a provider
|
||||
@@ -153,6 +156,7 @@ export function useAddonManager() {
|
||||
tripAddons,
|
||||
globalAddons,
|
||||
integrationAddons,
|
||||
aiAddons,
|
||||
providerOptions,
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type { Addon } from './addonModel';
|
||||
import { usesConnection } from './aiAddons';
|
||||
|
||||
/**
|
||||
* The AI tile behind both admin shells: its three switches, how many are on,
|
||||
* and the settings dialog. The dialog opens by itself when OpenRouter's
|
||||
* sign-in returns here (`?ai=openrouter`), since the connection form that
|
||||
* finishes it lives inside.
|
||||
*/
|
||||
export function useAiAddonTile(aiAddons: Addon[]) {
|
||||
const [params] = useSearchParams();
|
||||
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
|
||||
const canSetUp = usesConnection(aiAddons);
|
||||
return {
|
||||
enabledCount: aiAddons.filter((a) => a.enabled).length,
|
||||
canSetUp,
|
||||
dialogOpen: open && canSetUp,
|
||||
openDialog: () => setOpen(true),
|
||||
closeDialog: () => setOpen(false),
|
||||
};
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import { adminApi } from '../../../api/client';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
|
||||
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||
|
||||
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-011
|
||||
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-013
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
/** A local connection of parsing's own, so the form is open and lists the server's models. */
|
||||
const OWN_LOCAL = { provider: 'local', model: 'm' };
|
||||
|
||||
function llm(config?: Record<string, unknown>): Addon {
|
||||
return {
|
||||
@@ -45,7 +49,7 @@ describe('useLlmParsingConfig', () => {
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||
expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL);
|
||||
expect(result.current.isInstalled('qwen3.5:4b')).toBe(true);
|
||||
@@ -54,7 +58,7 @@ describe('useLlmParsingConfig', () => {
|
||||
|
||||
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
|
||||
useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt', baseUrl: ' http://box:11434/v1 ' }))
|
||||
);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
await act(() => result.current.loadModels());
|
||||
@@ -69,13 +73,13 @@ describe('useLlmParsingConfig', () => {
|
||||
|
||||
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED'));
|
||||
expect(result.current.installed).toEqual([]);
|
||||
expect(result.current.loadingModels).toBe(false);
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope');
|
||||
await act(() => result.current.loadModels());
|
||||
expect(result.current.modelsErr).toBe('Could not reach the local LLM server');
|
||||
expect(result.current.modelsErr).toBe('admin.addons.ai.parsing.unreachable');
|
||||
});
|
||||
|
||||
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
|
||||
@@ -83,7 +87,7 @@ describe('useLlmParsingConfig', () => {
|
||||
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
|
||||
});
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
|
||||
});
|
||||
|
||||
@@ -94,14 +98,14 @@ describe('useLlmParsingConfig', () => {
|
||||
progress = onProgress;
|
||||
return new Promise<void>((r) => (finish = r));
|
||||
});
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.pull('qwen3.5:4b');
|
||||
});
|
||||
expect(result.current.pulling).toBe('qwen3.5:4b');
|
||||
expect(result.current.pullStatus).toBe('starting…');
|
||||
expect(result.current.pullStatus).toBe('admin.addons.ai.parsing.pulling');
|
||||
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
|
||||
expect(result.current.pullStatus).toBe('downloading');
|
||||
expect(result.current.pullPct).toBe(25);
|
||||
@@ -109,7 +113,7 @@ describe('useLlmParsingConfig', () => {
|
||||
finish();
|
||||
await pending;
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Model pulled');
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled');
|
||||
expect(result.current.model).toBe('qwen3.5:4b');
|
||||
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.pulling).toBeNull();
|
||||
@@ -136,7 +140,7 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(toast.error).toHaveBeenCalledWith('manifest unknown');
|
||||
vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope');
|
||||
await act(() => result.current.pull('x'));
|
||||
expect(toast.error).toHaveBeenLastCalledWith('Pull failed');
|
||||
expect(toast.error).toHaveBeenLastCalledWith('admin.addons.ai.parsing.pullFailed');
|
||||
expect(result.current.pulling).toBeNull();
|
||||
});
|
||||
|
||||
@@ -148,18 +152,16 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', {
|
||||
config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' },
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Saved');
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.aiParsing.toast.saved');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
|
||||
act(() => {
|
||||
result.current.setProvider('anthropic');
|
||||
result.current.setVision('off');
|
||||
});
|
||||
await act(async () => result.current.setVision('off'));
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
await act(() => result.current.save());
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls.at(-1)?.[1]).toEqual({
|
||||
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
|
||||
});
|
||||
});
|
||||
@@ -212,7 +214,35 @@ describe('useLlmParsingConfig', () => {
|
||||
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
|
||||
await act(() => result.current.save());
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to save');
|
||||
expect(toast.error).toHaveBeenCalledWith('settings.aiParsing.toast.saveError');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' })));
|
||||
expect(result.current.ownConnection).toBe(false);
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
act(() => result.current.startOwn());
|
||||
expect(result.current.formOpen).toBe(true);
|
||||
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
|
||||
// The vision setting alone is saved without a connection.
|
||||
await act(async () => result.current.setVision('off'));
|
||||
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } });
|
||||
act(() => result.current.cancelOwn());
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' }))
|
||||
);
|
||||
expect(result.current.ownConnection).toBe(true);
|
||||
expect(result.current.savedModel).toBe('claude-x');
|
||||
await act(() => result.current.switchToShared());
|
||||
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } });
|
||||
expect(result.current.ownConnection).toBe(false);
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
expect(result.current.model).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,24 +2,47 @@ import { type LlmVision, asLlmVision } from '@trek/shared';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
|
||||
|
||||
/** What AI parsing keeps in its addon config: a connection of its own (or none) and the vision setting. */
|
||||
interface SavedParsing {
|
||||
provider: string;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
vision: LlmVision;
|
||||
}
|
||||
|
||||
function savedOf(cfg: Record<string, unknown>): SavedParsing {
|
||||
return {
|
||||
provider: typeof cfg.provider === 'string' ? cfg.provider : '',
|
||||
model: typeof cfg.model === 'string' ? cfg.model : '',
|
||||
baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : '',
|
||||
apiKey: typeof cfg.apiKey === 'string' ? cfg.apiKey : '',
|
||||
vision: asLlmVision(cfg.vision),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
|
||||
* whole instance and overrides per-user config (see server llmConfig.ts). The API key is
|
||||
* masked on read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider it also lists the installed Ollama models and can pull the recommended ones.
|
||||
* AI parsing's admin settings behind both admin shells. Parsing reads with the
|
||||
* AI connection the assistant runs on unless the instance keeps one of its own
|
||||
* here; an install set up before the two shared one keeps its own until the
|
||||
* admin switches it over. The API key is masked on read and an unchanged mask
|
||||
* keeps the stored key. For a local server it lists the installed Ollama models
|
||||
* and pulls the recommended ones.
|
||||
*/
|
||||
export function useLlmParsingConfig(addon: Addon) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const cfg = addon.config ?? {};
|
||||
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
|
||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
|
||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
|
||||
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
|
||||
const [saved, setSaved] = useState<SavedParsing>(() => savedOf(addon.config ?? {}));
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [provider, setProviderState] = useState<string>(saved.provider || 'local');
|
||||
const [model, setModel] = useState<string>(saved.model);
|
||||
const [baseUrl, setBaseUrl] = useState<string>(saved.baseUrl);
|
||||
const [apiKey, setApiKey] = useState<string>(saved.apiKey);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Local-provider model management.
|
||||
@@ -30,6 +53,10 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
const [pullPct, setPullPct] = useState(0);
|
||||
const [pullStatus, setPullStatus] = useState('');
|
||||
|
||||
/** A connection of its own, which wins over the shared one. */
|
||||
const ownConnection = !!(saved.provider && saved.model);
|
||||
const formOpen = ownConnection || editing;
|
||||
|
||||
/**
|
||||
* A different provider never inherits the last one's endpoint: an Ollama address
|
||||
* left in the field would quietly receive the OpenAI or Anthropic key. Back on the
|
||||
@@ -37,7 +64,7 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
* keep an address the admin typed for it (#2655); empty means the provider default.
|
||||
*/
|
||||
const setProvider = (next: string) => {
|
||||
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
|
||||
if (next !== provider) setBaseUrl(next === saved.provider ? saved.baseUrl : '');
|
||||
setProviderState(next);
|
||||
};
|
||||
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
|
||||
@@ -54,38 +81,38 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
setInstalled(res.models.map((m) => m.name));
|
||||
} catch (e: unknown) {
|
||||
// The server says why (unreachable, key refused); axios' own status line does not.
|
||||
setModelsErr(getApiErrorMessage(e, 'Could not reach the local LLM server'));
|
||||
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
|
||||
setInstalled([]);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load installed models when the local provider is active. Only a provider change
|
||||
// triggers it; the base URL is read as it stands then (its own blur reloads).
|
||||
// Load installed models while the form shows the local provider. Only a provider
|
||||
// change (or opening the form) triggers it; the base URL's own blur reloads.
|
||||
const loadModelsForProvider = useEffectEvent(() => {
|
||||
if (provider === 'local') void loadModels();
|
||||
if (formOpen && provider === 'local') void loadModels();
|
||||
});
|
||||
useEffect(() => {
|
||||
loadModelsForProvider();
|
||||
}, [provider]);
|
||||
}, [provider, formOpen]);
|
||||
|
||||
const pull = async (id: string) => {
|
||||
if (pulling) return;
|
||||
setPulling(id);
|
||||
setPullPct(0);
|
||||
setPullStatus('starting…');
|
||||
setPullStatus(t('admin.addons.ai.parsing.pulling'));
|
||||
try {
|
||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||
if (p.error) throw new Error(p.error);
|
||||
if (p.status) setPullStatus(p.status);
|
||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
|
||||
});
|
||||
toast.success('Model pulled');
|
||||
toast.success(t('admin.addons.ai.parsing.pulled'));
|
||||
setModel(id);
|
||||
await loadModels();
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Pull failed');
|
||||
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
|
||||
} finally {
|
||||
setPulling(null);
|
||||
setPullPct(0);
|
||||
@@ -93,27 +120,52 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
/** Writes the config; the stored key stays as long as the mask is sent in its place. */
|
||||
const write = async (next: SavedParsing, config: Record<string, unknown>): Promise<boolean> => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Send the masked sentinel unchanged so the server keeps the stored key.
|
||||
await adminApi.updateAddon(addon.id, {
|
||||
config: {
|
||||
provider,
|
||||
model: model.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiKey,
|
||||
vision,
|
||||
},
|
||||
});
|
||||
toast.success('Saved');
|
||||
await adminApi.updateAddon(addon.id, { config });
|
||||
setSaved(next);
|
||||
toast.success(t('settings.aiParsing.toast.saved'));
|
||||
return true;
|
||||
} catch {
|
||||
toast.error('Failed to save');
|
||||
toast.error(t('settings.aiParsing.toast.saveError'));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ownConfig = (s: SavedParsing) => ({
|
||||
provider: s.provider,
|
||||
model: s.model,
|
||||
baseUrl: s.baseUrl,
|
||||
apiKey: s.apiKey,
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
const next: SavedParsing = { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, vision: saved.vision };
|
||||
if (await write(next, { ...ownConfig(next), vision: next.vision })) setEditing(false);
|
||||
};
|
||||
|
||||
/** Back on the shared connection: the own one and its key are dropped, the vision setting stays. */
|
||||
const switchToShared = async () => {
|
||||
const next: SavedParsing = { provider: '', model: '', baseUrl: '', apiKey: '', vision: saved.vision };
|
||||
if (await write(next, { vision: next.vision })) {
|
||||
setEditing(false);
|
||||
setProviderState('local');
|
||||
setModel('');
|
||||
setBaseUrl('');
|
||||
setApiKey('');
|
||||
}
|
||||
};
|
||||
|
||||
/** Saved at once, with the connection as it was saved: a draft in the form is not sent along. */
|
||||
const setVision = (value: LlmVision) => {
|
||||
const next = { ...saved, vision: value };
|
||||
void write(next, ownConnection ? { ...ownConfig(saved), vision: value } : { vision: value });
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
setProvider,
|
||||
@@ -124,9 +176,16 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
baseUrlPlaceholder,
|
||||
apiKey,
|
||||
setApiKey,
|
||||
vision,
|
||||
vision: saved.vision,
|
||||
setVision,
|
||||
saving,
|
||||
ownConnection,
|
||||
savedProvider: saved.provider,
|
||||
savedModel: saved.model,
|
||||
formOpen,
|
||||
startOwn: () => setEditing(true),
|
||||
cancelOwn: () => setEditing(false),
|
||||
switchToShared,
|
||||
installed,
|
||||
modelsErr,
|
||||
loadingModels,
|
||||
|
||||
@@ -226,10 +226,13 @@ describe('AssistantChangeCard: on the map', () => {
|
||||
expect(row).toHaveClass('bg-surface-hover');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-009: the days a waiting card touches read as a band, with the schedule folded away', () => {
|
||||
it('FE-AI-CARD-009: the days a waiting card touches read as named stops, with the schedule folded away', () => {
|
||||
plannerHere();
|
||||
renderCard([onTheMap('c1', 'Pantheon')]);
|
||||
expect(screen.getByText('Rome')).toBeInTheDocument();
|
||||
// Folded, each stop is a chip with its name, not a blank block behind a tooltip.
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Pantheon')).toHaveLength(2);
|
||||
expect(screen.queryByText('New')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show schedule' }));
|
||||
|
||||
@@ -25,14 +25,16 @@ import { blockKeyOf } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
import AssistantDayBand from './AssistantDayBand';
|
||||
import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { CHANGE_LABELS, type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { changePoints, type DayChange } from './dayAfterModel';
|
||||
import { useChangeHover } from './useChangeHover';
|
||||
import { useWeekDays } from './useWeekDays';
|
||||
|
||||
/**
|
||||
* The changes the assistant proposes in one go, as one card in plain words:
|
||||
@@ -235,7 +237,11 @@ function Receipt({
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={statusLabel} placement="top">
|
||||
<span role="img" aria-label={statusLabel} className={`flex h-7 w-5 items-center justify-center ${receipt.tone}`}>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={statusLabel}
|
||||
className={`flex h-7 w-5 items-center justify-center ${receipt.tone}`}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.4} className={item.state === 'applying' ? 'animate-spin' : ''} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -392,6 +398,21 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
const changes = useMemo(() => dayChangesOf(items, excluded), [items, excluded]);
|
||||
const undoable = items.filter((i) => undoableNow(i, undone, now)).map((i) => i.toolCallId);
|
||||
|
||||
const week = useWeekDays(pending);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
// A plan over a week reads from its grid; the single changes wait behind one line.
|
||||
const folded = week.length > 0 && !showAll;
|
||||
|
||||
const toggleDay = (ids: readonly string[], include: boolean) =>
|
||||
setExcluded((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of ids) {
|
||||
if (include) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggle = (toolCallId: string) =>
|
||||
setExcluded((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -420,18 +441,32 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
count={items.length}
|
||||
actions={<HeadActions open={open} items={items} changes={changes} undoable={undoable} />}
|
||||
/>
|
||||
{open && <AssistantDayBand changes={changes} />}
|
||||
{open && week.length > 0 && (
|
||||
<AssistantWeekOverview days={week} excluded={excluded} disabled={isRunning} onToggleDay={toggleDay} />
|
||||
)}
|
||||
{open && !folded && <AssistantDayBand changes={changes} />}
|
||||
{folded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mb-1 w-full rounded-lg px-2 py-1.5 text-center text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
{t('assistant.week.showAll', { count: pending.length })}
|
||||
</button>
|
||||
)}
|
||||
<div className={open ? 'divide-y divide-edge-faint' : ''}>
|
||||
{items.map((item) =>
|
||||
item.state === 'pending' ? (
|
||||
<PendingRow
|
||||
key={item.toolCallId}
|
||||
item={item}
|
||||
excluded={excluded.has(item.toolCallId)}
|
||||
withSwitch={pending.length > 1}
|
||||
disabled={isRunning}
|
||||
onToggle={() => toggle(item.toolCallId)}
|
||||
/>
|
||||
folded ? null : (
|
||||
<PendingRow
|
||||
key={item.toolCallId}
|
||||
item={item}
|
||||
excluded={excluded.has(item.toolCallId)}
|
||||
withSwitch={pending.length > 1}
|
||||
disabled={isRunning}
|
||||
onToggle={() => toggle(item.toolCallId)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Receipt
|
||||
key={item.toolCallId}
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function AssistantComposer({ tripId, tripTitle }: { tripId: numbe
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length > 0 && take(e.clipboardData.files)) e.preventDefault();
|
||||
}}
|
||||
className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed"
|
||||
className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed dark:bg-transparent"
|
||||
/>
|
||||
<AuiIf condition={(s) => s.thread.isRunning}>
|
||||
<ComposerPrimitive.Cancel
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, TriangleAlert } from 'lucide-react';
|
||||
import { ChevronDown, CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
@@ -7,38 +7,49 @@ import { useTripStore } from '../../store/tripStore';
|
||||
import { dayLabel } from '../../utils/dayLabel';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { buildDayAfter, changedDays, type DayAfter, type DayChange, type TimelineStop } from './dayAfterModel';
|
||||
import {
|
||||
buildDayAfter,
|
||||
changedDays,
|
||||
type DayAfter,
|
||||
type DayChange,
|
||||
dayIssues,
|
||||
stopIssue,
|
||||
type TimelineStop,
|
||||
} from './dayAfterModel';
|
||||
import { useChangeHover } from './useChangeHover';
|
||||
|
||||
/**
|
||||
* The days a waiting card touches, as they would read once it ran: one thin
|
||||
* band per day with a block per stop, so a glance shows where the suggestion
|
||||
* lands 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.
|
||||
* 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,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** Days drawn as a band; the rest are counted. */
|
||||
/** Days shown; the rest are counted. */
|
||||
const MAX_DAYS = 2;
|
||||
/** Stops shown as chips on a day; the rest are counted. */
|
||||
const MAX_CHIPS = 6;
|
||||
|
||||
const SEGMENT: Record<TimelineStop['status'], string> = {
|
||||
kept: 'bg-surface-secondary',
|
||||
new: 'border border-dashed border-success bg-success-soft',
|
||||
removed: 'border border-edge-faint opacity-50',
|
||||
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',
|
||||
};
|
||||
|
||||
function Segment({ stop }: { stop: TimelineStop }) {
|
||||
/** 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 ? 'ring-1 ring-inset ring-danger' : '';
|
||||
const clash = stop.lead?.overlapsWith ? 'border-danger' : stopIssue(stop) ? 'border-warning' : '';
|
||||
return (
|
||||
<Tooltip label={stop.name} placement="top">
|
||||
<span
|
||||
{...(stop.toolCallId ? handlers : {})}
|
||||
className={`h-full min-w-[6px] flex-1 rounded-sm transition-shadow ${SEGMENT[stop.status]} ${clash} ${lit ? 'ring-2 ring-inset ring-accent' : ''}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<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' : ''}`}
|
||||
>
|
||||
<bdi className="truncate">{stop.name}</bdi>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,13 +58,19 @@ function Lead({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
|
||||
const lead = stop.lead;
|
||||
if (!lead) return null;
|
||||
const travel = lead.travelMinutes !== null && lead.travelMinutes > 0;
|
||||
if (!travel && lead.freeMinutes === null && !lead.overlapsWith) return null;
|
||||
if (!travel && lead.freeMinutes === null && !lead.overlapsWith && lead.shortBy === null) return null;
|
||||
return (
|
||||
<li
|
||||
className={`flex flex-wrap items-center gap-x-2 text-caption text-content-faint ${timed ? 'ps-[3.25rem]' : 'ps-1'}`}
|
||||
>
|
||||
{travel && <span>{t('assistant.change.timeline.travel', { count: lead.travelMinutes ?? 0 })}</span>}
|
||||
{lead.freeMinutes !== null && <span>{t('assistant.change.timeline.gap', { count: lead.freeMinutes })}</span>}
|
||||
{lead.shortBy !== null && (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-warning">
|
||||
<TriangleAlert size={11} aria-hidden="true" className="flex-shrink-0" />
|
||||
{t('assistant.change.timeline.tight', { count: lead.shortBy })}
|
||||
</span>
|
||||
)}
|
||||
{lead.overlapsWith && (
|
||||
<span className="inline-flex items-center gap-1 text-content-muted">
|
||||
<TriangleAlert size={11} aria-hidden="true" className="flex-shrink-0 text-danger" />
|
||||
@@ -91,16 +108,20 @@ function StopLine({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
|
||||
|
||||
function DayRow({ day, label, open }: { day: DayAfter; label: string; open: boolean }) {
|
||||
const timed = day.stops.some((s) => s.time !== null);
|
||||
const chips = day.stops.slice(0, MAX_CHIPS);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<bdi className="w-16 flex-shrink-0 truncate text-caption text-content-muted">{label}</bdi>
|
||||
<div className="flex h-6 min-w-0 flex-1 gap-0.5 overflow-hidden" aria-hidden="true">
|
||||
{day.stops.map((stop) => (
|
||||
<Segment key={stop.key} stop={stop} />
|
||||
<bdi className="block truncate text-caption font-medium text-content-muted">{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} />
|
||||
))}
|
||||
{day.stops.length > MAX_CHIPS && (
|
||||
<span className="text-caption tabular-nums text-content-faint">+{day.stops.length - MAX_CHIPS}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{open && (
|
||||
<ol className="trek-assistant-enter mt-1 space-y-0.5" aria-label={label}>
|
||||
{day.stops.map((stop) => (
|
||||
@@ -153,6 +174,8 @@ export default function AssistantDayBand({ changes }: { changes: readonly DayCha
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
const shown = rows.slice(0, MAX_DAYS);
|
||||
const issues = rows.reduce((sum, row) => sum + dayIssues(row.after), 0);
|
||||
const measured = rows.some((row) => row.after.stops.some((stop) => stop.lead?.travelMinutes != null));
|
||||
const toggleLabel = t(open ? 'assistant.change.timeline.hide' : 'assistant.change.timeline.show');
|
||||
return (
|
||||
<div className="mb-1 flex gap-1.5">
|
||||
@@ -161,18 +184,31 @@ export default function AssistantDayBand({ changes }: { changes: readonly DayCha
|
||||
<DayRow key={after.dayId} day={after} label={label} open={open} />
|
||||
))}
|
||||
{rows.length > MAX_DAYS && (
|
||||
<p className="ps-[4.5rem] text-caption text-content-faint">
|
||||
<p className="text-caption text-content-faint">
|
||||
{t('assistant.change.timeline.moreDays', { count: rows.length - MAX_DAYS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(issues > 0 || measured) && (
|
||||
// What the check found, at a glance; a tap opens the schedule that says where.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={`inline-flex h-6 flex-shrink-0 items-center gap-1 self-start rounded-full px-2 text-caption font-medium transition-colors ${
|
||||
issues > 0 ? 'bg-warning-soft text-warning hover:brightness-95' : 'bg-success-soft text-success'
|
||||
}`}
|
||||
>
|
||||
{issues > 0 ? <TriangleAlert size={12} aria-hidden="true" /> : <CircleCheck size={12} aria-hidden="true" />}
|
||||
{issues > 0 ? t('assistant.check.issues', { count: issues }) : t('assistant.check.ok')}
|
||||
</button>
|
||||
)}
|
||||
<Tooltip label={toggleLabel} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label={toggleLabel}
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-content-muted outline-none transition-colors hover:bg-surface-hover hover:text-content focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { AssistantChangeEffect, AssistantMessage } from '@trek/shared';
|
||||
import { ArrowRightLeft, Minus, Sparkles } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { changeEffects } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
|
||||
/** What a waiting change does to one day, as a row of the preview. */
|
||||
interface PreviewRow {
|
||||
toolCallId: string;
|
||||
kind: 'add' | 'in' | 'remove' | 'out';
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** The changes no one has decided on yet, by their call. */
|
||||
function waitingCalls(messages: readonly AssistantMessage[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const m of messages) {
|
||||
for (const p of m.parts) {
|
||||
if (p.type === 'tool-call' && p.approval && !p.approval.resolution && p.approval.approved === undefined) {
|
||||
ids.add(p.toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function rowOf(
|
||||
toolCallId: string,
|
||||
effect: AssistantChangeEffect,
|
||||
dayId: number,
|
||||
nameOf: (id: number | null) => string | null
|
||||
) {
|
||||
const name = effect.place?.name ?? nameOf(effect.placeId);
|
||||
if (!name) return null;
|
||||
if (effect.dayId === dayId) {
|
||||
const kind = effect.kind === 'remove' ? 'remove' : effect.kind === 'move' ? 'in' : 'add';
|
||||
return { toolCallId, kind, name } satisfies PreviewRow;
|
||||
}
|
||||
if (effect.kind === 'move' && effect.fromDayId === dayId)
|
||||
return { toolCallId, kind: 'out', name } satisfies PreviewRow;
|
||||
return null;
|
||||
}
|
||||
|
||||
const LOOK: Record<PreviewRow['kind'], { icon: typeof Sparkles; row: string; name: string; badgeKey: string }> = {
|
||||
add: {
|
||||
icon: Sparkles,
|
||||
row: 'border-dashed border-accent bg-accent-subtle',
|
||||
name: 'text-content',
|
||||
badgeKey: 'assistant.preview.suggested',
|
||||
},
|
||||
in: {
|
||||
icon: ArrowRightLeft,
|
||||
row: 'border-dashed border-accent bg-accent-subtle',
|
||||
name: 'text-content',
|
||||
badgeKey: 'assistant.preview.movedHere',
|
||||
},
|
||||
remove: {
|
||||
icon: Minus,
|
||||
row: 'border-dashed border-edge',
|
||||
name: 'text-content-faint line-through',
|
||||
badgeKey: 'assistant.preview.removed',
|
||||
},
|
||||
out: {
|
||||
icon: ArrowRightLeft,
|
||||
row: 'border-dashed border-edge',
|
||||
name: 'text-content-faint line-through',
|
||||
badgeKey: 'assistant.preview.movedAway',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The assistant's waiting changes inside the day they touch, in the planner's
|
||||
* own day list: a place it would add as a dashed row, one it would take off
|
||||
* struck through, a move on both of its days. Resting on a row lights the
|
||||
* change's pin and card; a click brings the chat forward to decide. Nothing
|
||||
* here writes; the change card stays the one place a change is applied.
|
||||
*/
|
||||
export default function AssistantDayPreview({ tripId, dayId }: { tripId: number; dayId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
|
||||
const hovered = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
|
||||
const { setMapHover, open } = useAssistantStore(useShallow((s) => ({ setMapHover: s.setMapHover, open: s.open })));
|
||||
const places = useTripStore((s) => s.places);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const waiting = waitingCalls(messages);
|
||||
if (waiting.size === 0) return [];
|
||||
const nameOf = (id: number | null) => (id === null ? null : (places.find((p) => p.id === id)?.name ?? null));
|
||||
const out: PreviewRow[] = [];
|
||||
for (const [toolCallId, effect] of changeEffects(messages)) {
|
||||
if (!waiting.has(toolCallId)) continue;
|
||||
const row = rowOf(toolCallId, effect, dayId, nameOf);
|
||||
if (row) out.push(row);
|
||||
}
|
||||
return out;
|
||||
}, [messages, places, dayId]);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 px-3 pb-2 pt-1" aria-label={t('assistant.preview.title')}>
|
||||
{rows.map((row, i) => {
|
||||
const look = LOOK[row.kind];
|
||||
const Icon = look.icon;
|
||||
const lit = hovered === row.toolCallId;
|
||||
return (
|
||||
<li
|
||||
key={`${row.toolCallId}-${row.kind}`}
|
||||
className="trek-assistant-enter"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
onMouseEnter={() => setMapHover(tripId, row.toolCallId, 'card')}
|
||||
onMouseLeave={() => setMapHover(tripId, null, 'card')}
|
||||
className={`flex w-full items-center gap-2 rounded-xl border px-2.5 py-1.5 text-start transition-all ${look.row} ${
|
||||
lit ? 'shadow-md ring-2 ring-accent' : 'hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-lg bg-surface-card text-content-muted shadow-sm">
|
||||
<Icon size={12} aria-hidden="true" />
|
||||
</span>
|
||||
<bdi className={`min-w-0 flex-1 truncate text-caption font-medium ${look.name}`}>{row.name}</bdi>
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-card px-2 py-0.5 text-caption text-content-muted shadow-sm">
|
||||
{t(look.badgeKey)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { AssistantMessage } from '@trek/shared';
|
||||
import { CornerDownRight } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { followUpsFor } from './assistantFollowUpRules';
|
||||
import { hasUndecided } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
|
||||
/** Each chip comes in a moment after the one before it. */
|
||||
const STAGGER_MS = 60;
|
||||
|
||||
/**
|
||||
* The next questions under the latest answer, one tap each. Only under the
|
||||
* last answer, once it is written, and not while changes wait for a decision:
|
||||
* then the cards are what to answer.
|
||||
*/
|
||||
export default function AssistantFollowUps({ message }: { message: AssistantMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const can = useCanDo();
|
||||
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
|
||||
const send = useAssistantStore((s) => s.send);
|
||||
const shown = useAssistantStore((s) => {
|
||||
const chat = chatOf(s, tripId);
|
||||
const last = chat.messages[chat.messages.length - 1];
|
||||
return !chat.isRunning && !chat.error && last?.id === message.id && !hasUndecided(chat.messages);
|
||||
});
|
||||
if (!shown) return null;
|
||||
const chips = followUpsFor(message, !!trip && can('place_edit', trip));
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div role="group" aria-label={t('assistant.followUp.title')} className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{chips.map((chip, i) => {
|
||||
const text = t(chip.labelKey);
|
||||
return (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
onClick={() => void send(tripId, text)}
|
||||
className="trek-assistant-enter group/chip inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-card px-3 py-1.5 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md hover:ring-edge active:translate-y-0"
|
||||
style={{ animationDelay: `${i * STAGGER_MS}ms` }}
|
||||
>
|
||||
<CornerDownRight
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 text-content-faint transition-colors group-hover/chip:text-content rtl:-scale-x-100"
|
||||
/>
|
||||
<span className="truncate">{text}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import { Ellipsis, History, Lightbulb, LightbulbOff, Plus, Settings, Trash2, X } from 'lucide-react';
|
||||
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
|
||||
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { PopoverItem } from '../Packing/PackingPopover';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { NEUTRAL_TINT } from '../shared/DialogShell';
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu';
|
||||
import { NEUTRAL_TINT } from '../shared/DialogShell';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { usePopoverList } from '../shared/usePopoverList';
|
||||
import AssistantMemoryDialog from './AssistantMemoryDialog';
|
||||
import { AI_SETTINGS_PATH } from './assistantParts';
|
||||
import { useAssistantHintsSetting } from './useAssistantHints';
|
||||
import { useAssistantLiveStep } from './useAssistantLiveStep';
|
||||
import type { useAssistantThread } from './useAssistantThread';
|
||||
import { useTripMemory } from './useTripMemory';
|
||||
|
||||
// The round, raised buttons of TREK's dialog heads.
|
||||
const ICON_BUTTON =
|
||||
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content';
|
||||
|
||||
interface AssistantHeaderProps {
|
||||
tripId: number;
|
||||
thread: ReturnType<typeof useAssistantThread>;
|
||||
tripTitle: string;
|
||||
onClose: () => void;
|
||||
@@ -34,8 +38,11 @@ interface AssistantHeaderProps {
|
||||
* keeps the conversation, and an answer being written goes on. The model is not picked here;
|
||||
* that lives in the settings.
|
||||
*/
|
||||
export default function AssistantHeader({ thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
|
||||
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const memory = useTripMemory(tripId);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const menu = useContextMenu();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
@@ -90,9 +97,20 @@ export default function AssistantHeader({ thread, tripTitle, onClose, dragProps
|
||||
className="flex min-w-0 flex-1 cursor-grab touch-none select-none items-center active:cursor-grabbing"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 truncate font-geist text-caption font-bold uppercase tracking-[.08em] text-content-muted">
|
||||
{t('assistant.launcher.tooltip')}
|
||||
</p>
|
||||
{thread.isRunning ? (
|
||||
// While it works, the line over the title says what it does right now.
|
||||
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
|
||||
/>
|
||||
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="m-0 truncate font-geist text-caption font-bold uppercase tracking-[.08em] text-content-muted">
|
||||
{t('assistant.launcher.tooltip')}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="m-0 truncate text-body font-bold tracking-[-0.01em] text-content">
|
||||
<bdi>{thread.title ?? tripTitle}</bdi>
|
||||
</h2>
|
||||
@@ -104,6 +122,22 @@ export default function AssistantHeader({ thread, tripTitle, onClose, dragProps
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMemoryOpen(true)}
|
||||
aria-label={t('assistant.memory.title')}
|
||||
className={`relative ${ICON_BUTTON}`}
|
||||
>
|
||||
<NotebookPen size={13} aria-hidden="true" />
|
||||
{memory.has && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -end-0.5 -top-0.5 h-2 w-2 rounded-full bg-accent ring-2 ring-[color:var(--bg-elevated)]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('assistant.history.open')}>
|
||||
<button
|
||||
ref={history.triggerRef}
|
||||
@@ -167,6 +201,7 @@ export default function AssistantHeader({ thread, tripTitle, onClose, dragProps
|
||||
</PopoverListPanel>
|
||||
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
onClose={() => setConfirming(false)}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NotebookPen, Plus } from 'lucide-react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { TEXTAREA } from '../shared/dialogParts';
|
||||
import { MAX_MEMORY_CHARS, useTripMemory } from './useTripMemory';
|
||||
|
||||
/** What travellers most often want minded, one tap to add each. */
|
||||
const QUICK = ['vegetarian', 'kids', 'budget', 'slow', 'accessible', 'noMuseums'] as const;
|
||||
|
||||
/**
|
||||
* The trip's notes for the assistant: a few lines it follows in every
|
||||
* suggestion on this trip, written once instead of in every question. The
|
||||
* common ones are a tap away; the rest is free text.
|
||||
*/
|
||||
export default function AssistantMemoryDialog({ tripId, onClose }: { tripId: number; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const memory = useTripMemory(tripId);
|
||||
const [draft, setDraft] = useState(memory.text);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const add = (line: string) =>
|
||||
setDraft((prev) => (prev.includes(line) ? prev : `${prev.trimEnd()}${prev.trim() ? '\n' : ''}${line}`));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
const done = await memory.save(draft);
|
||||
setSaving(false);
|
||||
if (done) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
discardGuard={draft}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<NotebookPen size={20} strokeWidth={2} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
eyebrow={t('assistant.launcher.tooltip')}
|
||||
title={t('assistant.memory.title')}
|
||||
sub={t('assistant.memory.sub')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<span className="text-caption tabular-nums text-content-faint">
|
||||
{draft.length}/{MAX_MEMORY_CHARS}
|
||||
</span>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" disabled={saving} onClick={() => void save()}>
|
||||
{t(saving ? 'common.saving' : 'common.save')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<DialogSection label={t('assistant.memory.quick')}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{QUICK.map((key, i) => {
|
||||
const line = t(`assistant.memory.quick.${key}`);
|
||||
const taken = draft.includes(line);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={taken}
|
||||
onClick={() => add(line)}
|
||||
className="trek-assistant-enter inline-flex items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md disabled:translate-y-0 disabled:opacity-40 disabled:shadow-none"
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<Plus size={11} aria-hidden="true" />
|
||||
{line}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogSection>
|
||||
<DialogSection label={t('assistant.memory.notes')}>
|
||||
<textarea
|
||||
aria-label={t('assistant.memory.notes')}
|
||||
rows={6}
|
||||
maxLength={MAX_MEMORY_CHARS}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={t('assistant.memory.placeholder')}
|
||||
className={TEXTAREA}
|
||||
/>
|
||||
</DialogSection>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantChangeCard from './AssistantChangeCard';
|
||||
import AssistantFollowUps from './AssistantFollowUps';
|
||||
import AssistantMarkdown from './AssistantMarkdown';
|
||||
import AssistantMentionText from './AssistantMentionText';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
@@ -74,7 +75,9 @@ function AttachmentChip({ name, size }: { name: string; size: number }) {
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<bdi className="truncate text-body font-medium text-content">{name}</bdi>
|
||||
<span className="text-caption tabular-nums text-content-muted">{kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`}</span>
|
||||
<span className="text-caption tabular-nums text-content-muted">
|
||||
{kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
@@ -149,6 +152,7 @@ function Answer({ message }: { message: StoredMessage }) {
|
||||
</div>
|
||||
)}
|
||||
{share.canShare && <AssistantShareDialog share={share} />}
|
||||
<AssistantFollowUps message={message} />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, ChevronDown, LoaderCircle, MapPin, Plus } from 'lucide-react';
|
||||
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { PopoverItem } from '../Packing/PackingPopover';
|
||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
@@ -22,7 +21,7 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
|
||||
const { t } = useTranslation();
|
||||
const { days, focusDay, disabled, blockedKey, adding } = strip;
|
||||
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
|
||||
estimatedHeight: Math.min(320, days.length * 34 + 8),
|
||||
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
|
||||
});
|
||||
const height = tall ? 'h-10' : 'h-7';
|
||||
const busy = adding === place.ref;
|
||||
@@ -91,26 +90,44 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
|
||||
) : (
|
||||
buttons
|
||||
)}
|
||||
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="max-w-[16rem]">
|
||||
{days.map((day) => (
|
||||
<PopoverItem
|
||||
key={day.id}
|
||||
icon={null}
|
||||
label={day.label}
|
||||
active={day.id === focusDay?.id}
|
||||
onClick={() => pick(day.id)}
|
||||
/>
|
||||
))}
|
||||
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
|
||||
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day) => {
|
||||
const current = day.id === focusDay?.id;
|
||||
return (
|
||||
<Tooltip key={day.id} label={day.label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(day.id)}
|
||||
aria-label={day.label}
|
||||
aria-pressed={current}
|
||||
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="text-body font-semibold tabular-nums">{day.n}</span>
|
||||
{day.weekday && (
|
||||
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
|
||||
{day.weekday}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One place a search found, as a small card: its picture from TREK's photo
|
||||
* proxy (or its category's icon), its name, the category it most likely
|
||||
* belongs in, whether it is open now while the trip runs, how far it is from
|
||||
* the last stop of the day in focus, and one way on: "+ Day 3" for a new
|
||||
* One place a search found, as a card: its picture from TREK's photo proxy
|
||||
* with the category it most likely belongs in on it (or that category's icon
|
||||
* on its colour), its name, address and rating, whether it is open now while
|
||||
* the trip runs, how far it is from the last stop of the day in focus, and one
|
||||
* way on: "+ Day 3" for a new
|
||||
* place, "On map" for one the trip has. A click on the card shows it on the
|
||||
* map, and resting the pointer on it pins it there for a moment.
|
||||
*/
|
||||
@@ -156,8 +173,8 @@ export default function AssistantPlaceCard({
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
className={`flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-card ${
|
||||
density === 'compact' ? 'w-[168px]' : 'w-[200px]'
|
||||
className={`group/card flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm transition-shadow hover:shadow-md ${
|
||||
density === 'compact' ? 'w-[188px]' : 'w-[220px]'
|
||||
}`}
|
||||
onMouseEnter={() => strip.hover(place)}
|
||||
onMouseLeave={() => strip.hover(null)}
|
||||
@@ -169,36 +186,62 @@ export default function AssistantPlaceCard({
|
||||
onBlur={() => strip.hover(null)}
|
||||
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
|
||||
>
|
||||
<span className="flex h-[72px] w-full items-center justify-center overflow-hidden bg-surface-secondary">
|
||||
<span
|
||||
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
|
||||
// Without a picture the category's own colour washes the space, data from the trip.
|
||||
style={
|
||||
photo || !category
|
||||
? undefined
|
||||
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
|
||||
}
|
||||
>
|
||||
{photo ? (
|
||||
<img src={photo} alt="" decoding="async" loading="lazy" className="h-full w-full object-cover" />
|
||||
<img
|
||||
src={photo}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
|
||||
/>
|
||||
) : (
|
||||
// The category's own colour, data from the trip; only the small icon wears it.
|
||||
<Icon size={20} aria-hidden="true" style={{ color: category?.color ?? 'var(--text-faint)' }} />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-0.5 px-2.5 pt-2">
|
||||
<bdi className="truncate text-body font-semibold text-content">{place.name}</bdi>
|
||||
{(category || place.openNow !== null) && (
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-content-muted">
|
||||
{category && <bdi className="min-w-0 truncate">{category.name}</bdi>}
|
||||
{place.openNow === true && (
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-1">
|
||||
<span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{t('assistant.place.openNow')}
|
||||
</span>
|
||||
)}
|
||||
{place.openNow === false && <span className="flex-shrink-0">{t('assistant.place.closed')}</span>}
|
||||
<span className="grid h-full w-full place-items-center">
|
||||
<Icon size={30} aria-hidden="true" style={{ color: category?.color ?? 'var(--text-faint)' }} />
|
||||
</span>
|
||||
)}
|
||||
{distance && (
|
||||
<span className="truncate text-caption text-content-faint">
|
||||
{t('assistant.place.fromPrev', { distance })}
|
||||
{category && photo && (
|
||||
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
|
||||
<Icon size={11} aria-hidden="true" className="flex-shrink-0" style={{ color: category.color }} />
|
||||
<bdi className="truncate">{category.name}</bdi>
|
||||
</span>
|
||||
)}
|
||||
{place.openNow !== null && (
|
||||
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
|
||||
/>
|
||||
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-1 px-3 pt-2.5">
|
||||
<bdi className="line-clamp-2 text-body font-semibold leading-snug text-content">{place.name}</bdi>
|
||||
{category && !photo && <bdi className="truncate text-caption text-content-muted">{category.name}</bdi>}
|
||||
{place.address && <bdi className="truncate text-caption text-content-muted">{place.address}</bdi>}
|
||||
{(place.rating !== null || distance) && (
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-content-faint">
|
||||
{place.rating !== null && (
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-0.5 font-medium text-content-muted">
|
||||
<Star size={11} aria-hidden="true" className="fill-current text-warning" />
|
||||
{place.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{distance && <span className="truncate">{t('assistant.place.fromPrev', { distance })}</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mt-auto flex min-w-0 px-2.5 pb-2.5 pt-2">{action}</div>
|
||||
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">{action}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import {
|
||||
buildAssignment,
|
||||
buildCategory,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
buildTrip,
|
||||
buildUser,
|
||||
} from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { fetchPhoto } from '../../services/photoService';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -119,12 +119,18 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(screen.getByText('1.2 km from the previous stop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-002: shows six cards and counts the rest on a tile', () => {
|
||||
it('FE-AI-PLACES-002: shows every card it was sent and counts what the server left out on a tile', () => {
|
||||
seed();
|
||||
const list = Array.from({ length: 8 }, (_, i) => place(`s1:${i}`));
|
||||
renderStrip({ toolCallId: 's1', places: list, more: 4 });
|
||||
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(6);
|
||||
expect(screen.getByText('6 more')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(8);
|
||||
expect(screen.getByText('4 more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-009: a place known only by its position shows its category, never a photo of the area', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0', { photoKey: 'coords:41.889000,12.470000' })], more: 0 });
|
||||
expect(fetchPhoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-003: a picture only ever comes from the photo proxy', async () => {
|
||||
|
||||
@@ -3,16 +3,18 @@ import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { prefersReducedMotion } from '../../utils/reducedMotion';
|
||||
import AssistantPlaceCard from './AssistantPlaceCard';
|
||||
import { suggestedFromStrip } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||
|
||||
/** Cards the strip shows; the rest are counted on the tile at its end. */
|
||||
const SHOWN = 6;
|
||||
/** Cards the strip shows, every one the server sends; what it left out is counted on the tile at its end. */
|
||||
const SHOWN = 20;
|
||||
|
||||
// In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow.
|
||||
const ARROW =
|
||||
'absolute top-[52px] z-10 hidden h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border border-edge-faint bg-surface-card text-content-secondary shadow-dropdown hover:text-content group-hover:flex';
|
||||
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content disabled:cursor-default disabled:opacity-40 disabled:hover:text-content-secondary';
|
||||
|
||||
/** How far the strip has scrolled from either end, with the reading direction in mind. */
|
||||
function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
|
||||
@@ -24,8 +26,9 @@ function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
|
||||
/**
|
||||
* The places a search found, as one row of cards to swipe or scroll through:
|
||||
* the next card peeks in, so the row says it goes on, and the edges fade
|
||||
* where there is more. On the desktop two arrows show on hover while the row
|
||||
* overflows. At most six cards, then a tile counting the rest.
|
||||
* where there is more. On the desktop a head over the row names it and holds
|
||||
* two arrows that move it by whole cards, off while that way is the end. Every card the step found, then a
|
||||
* tile counting what the server left out.
|
||||
*/
|
||||
export default function AssistantPlaceStrip({ found, message }: { found: AssistantPlaces; message: AssistantMessage }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -55,22 +58,63 @@ export default function AssistantPlaceStrip({ found, message }: { found: Assista
|
||||
const { hover } = strip;
|
||||
useEffect(() => () => hover(null), [hover]);
|
||||
|
||||
/** Scrolls by most of a view towards the start (-1) or the end (1) of the row. */
|
||||
/**
|
||||
* Scrolls by as many whole cards as the view holds, towards the start (-1)
|
||||
* or the end (1), so the row lands on a card edge instead of fighting its
|
||||
* snap points half way; smoothly, unless motion is turned down.
|
||||
*/
|
||||
const page = (towards: -1 | 1) => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const card = el?.firstElementChild as HTMLElement | null;
|
||||
if (!el || !card) return;
|
||||
const gap = Number.parseFloat(getComputedStyle(el).columnGap) || 0;
|
||||
const step = card.offsetWidth + gap;
|
||||
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
|
||||
const rtl = getComputedStyle(el).direction === 'rtl';
|
||||
el.scrollBy((rtl ? -towards : towards) * el.clientWidth * 0.8, 0);
|
||||
el.scrollBy({
|
||||
left: (rtl ? -towards : towards) * cards * step,
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const total = found.places.length + found.more;
|
||||
return (
|
||||
<section aria-label={t('assistant.place.strip')} className="group relative -mx-4">
|
||||
<section aria-label={t('assistant.place.strip')} className="-mx-4">
|
||||
{variant === 'window' && (fade.start || fade.end) && (
|
||||
// The cards are reachable by Tab; the arrows are for the pointer only.
|
||||
<div className="mb-1.5 flex items-center gap-2 px-4">
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-muted">
|
||||
{t('assistant.place.strip')}
|
||||
<span className="ms-1.5 tabular-nums text-content-faint">{total}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disabled={!fade.start}
|
||||
onClick={() => page(-1)}
|
||||
className={ARROW}
|
||||
>
|
||||
<ChevronLeft size={15} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disabled={!fade.end}
|
||||
onClick={() => page(1)}
|
||||
className={ARROW}
|
||||
>
|
||||
<ChevronRight size={15} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
ref={listRef}
|
||||
onScroll={measure}
|
||||
data-fade-start={fade.start || undefined}
|
||||
data-fade-end={fade.end || undefined}
|
||||
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2 overflow-x-auto px-4 pb-1"
|
||||
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5"
|
||||
>
|
||||
{found.places.slice(0, SHOWN).map((place) => (
|
||||
<AssistantPlaceCard
|
||||
@@ -81,32 +125,11 @@ export default function AssistantPlaceStrip({ found, message }: { found: Assista
|
||||
/>
|
||||
))}
|
||||
{rest > 0 && (
|
||||
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
|
||||
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-2xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
|
||||
{t('assistant.place.more', { count: rest })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{variant === 'window' && (
|
||||
// The cards are reachable by Tab; the arrows are for the pointer only.
|
||||
<>
|
||||
{fade.start && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={() => page(-1)}
|
||||
className={`${ARROW} start-2`}
|
||||
>
|
||||
<ChevronLeft size={15} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
{fade.end && (
|
||||
<button type="button" tabIndex={-1} aria-hidden="true" onClick={() => page(1)} className={`${ARROW} end-2`}>
|
||||
<ChevronRight size={15} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* FE-AI-POWER-001 to FE-AI-POWER-010
|
||||
*
|
||||
* The parts that tie the chat into the planner: the next questions under an
|
||||
* answer, the waiting changes inside the day list, the week grid of a plan
|
||||
* over several days, the context menu rows, the trip notes and the part of the
|
||||
* map a question carries.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { AssistantChangePreview, AssistantMessage } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { mapViewOf } from '../Map/assistantViewport';
|
||||
import AssistantDayPreview from './AssistantDayPreview';
|
||||
import { followUpsFor } from './assistantFollowUpRules';
|
||||
import AssistantFollowUps from './AssistantFollowUps';
|
||||
import type { ChangeItem } from './assistantParts';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { useAssistantMenu } from './useAssistantMenu';
|
||||
import { useTripMemory } from './useTripMemory';
|
||||
import { useWeekDays } from './useWeekDays';
|
||||
|
||||
const TRIP = 9;
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
const TRIPS = useTripStore.getState();
|
||||
const SETTINGS = useSettingsStore.getState();
|
||||
const AUTH = useAuthStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
useTripStore.setState(TRIPS, true);
|
||||
useSettingsStore.setState(SETTINGS, true);
|
||||
useAuthStore.setState(AUTH, true);
|
||||
});
|
||||
|
||||
const view: AssistantView = { tripId: TRIP, variant: 'window' };
|
||||
const inView = (node: ReactNode) => <AssistantViewContext.Provider value={view}>{node}</AssistantViewContext.Provider>;
|
||||
|
||||
function answer(parts: AssistantMessage['parts'], id = 'a1'): AssistantMessage {
|
||||
return { id, role: 'assistant', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z', parts };
|
||||
}
|
||||
|
||||
function preview(
|
||||
toolCallId: string,
|
||||
dayId: number,
|
||||
over: Partial<AssistantChangePreview> = {}
|
||||
): AssistantChangePreview {
|
||||
return {
|
||||
toolCallId,
|
||||
verbKey: 'addPlace',
|
||||
subject: 'Place',
|
||||
rows: [],
|
||||
dayId,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
effect: { kind: 'add', dayId, fromDayId: null, placeId: null, assignmentId: null, place: null },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function waiting(toolCallId: string, data: AssistantChangePreview): AssistantMessage['parts'] {
|
||||
return [
|
||||
{ type: 'tool-call', toolCallId, toolName: 'create_place', args: {}, approval: { id: toolCallId } },
|
||||
{ type: 'data-trek-change', data },
|
||||
];
|
||||
}
|
||||
|
||||
function seedChat(messages: AssistantMessage[], extra: Record<string, unknown> = {}) {
|
||||
useAssistantStore.setState({
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages, ...extra } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('the next questions', () => {
|
||||
it('FE-AI-POWER-001: an answer offers what fits what it holds, changes only to someone who may make them', () => {
|
||||
const ids = (parts: AssistantMessage['parts'], canEdit = true) =>
|
||||
followUpsFor(answer(parts), canEdit).map((f) => f.id);
|
||||
expect(ids([{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } }])).toContain('cheaper');
|
||||
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }])).toContain('nextDay');
|
||||
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }], false)).toEqual([]);
|
||||
expect(ids([{ type: 'text', text: 'Hi' }])).toEqual(['more', 'next']);
|
||||
expect(ids([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-002: under the last finished answer a tap sends the question; not while it runs', () => {
|
||||
const send = vi.fn(async () => {});
|
||||
const last = answer([{ type: 'text', text: 'Done.' }]);
|
||||
seedChat([last]);
|
||||
useAssistantStore.setState({ send });
|
||||
const { rerender } = render(inView(<AssistantFollowUps message={last} />));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tell me more' }));
|
||||
expect(send).toHaveBeenCalledWith(TRIP, 'Tell me more');
|
||||
|
||||
seedChat([last], { isRunning: true });
|
||||
rerender(inView(<AssistantFollowUps message={last} />));
|
||||
expect(screen.queryByRole('button', { name: 'Tell me more' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the day list preview', () => {
|
||||
it('FE-AI-POWER-003: a waiting add shows dashed on its day, a removal struck, and resting on it lights the change', () => {
|
||||
useTripStore.setState({ places: [buildPlace({ id: 5, name: 'Louvre' })] });
|
||||
const add = preview('c1', 3, {
|
||||
effect: {
|
||||
kind: 'add',
|
||||
dayId: 3,
|
||||
fromDayId: null,
|
||||
placeId: null,
|
||||
assignmentId: null,
|
||||
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
|
||||
},
|
||||
});
|
||||
const remove = preview('c2', 3, {
|
||||
effect: { kind: 'remove', dayId: 3, fromDayId: null, placeId: 5, assignmentId: 50, place: null },
|
||||
});
|
||||
seedChat([answer([...waiting('c1', add), ...waiting('c2', remove)])]);
|
||||
render(<AssistantDayPreview tripId={TRIP} dayId={3} />);
|
||||
expect(screen.getByText('Pantheon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Louvre')).toHaveClass('line-through');
|
||||
fireEvent.mouseEnter(screen.getByText('Pantheon'));
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('c1');
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-004: nothing shows on another day, or once the change was decided', () => {
|
||||
const add = preview('c1', 3, {
|
||||
effect: {
|
||||
kind: 'add',
|
||||
dayId: 3,
|
||||
fromDayId: null,
|
||||
placeId: null,
|
||||
assignmentId: null,
|
||||
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
|
||||
},
|
||||
});
|
||||
seedChat([answer(waiting('c1', add))]);
|
||||
const { container } = render(<AssistantDayPreview tripId={TRIP} dayId={4} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the week grid', () => {
|
||||
const item = (id: string, dayId: number): ChangeItem => ({
|
||||
toolCallId: id,
|
||||
preview: preview(id, dayId),
|
||||
applied: null,
|
||||
state: 'pending',
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-005: from three days on the changes group by day, in trip order', () => {
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: TRIP }),
|
||||
days: [
|
||||
buildDay({ id: 1, date: '2026-11-02' }),
|
||||
buildDay({ id: 2, date: '2026-11-03' }),
|
||||
buildDay({ id: 3, date: '2026-11-04' }),
|
||||
],
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => inView(children);
|
||||
const three = renderHook(() => useWeekDays([item('a', 3), item('b', 1), item('c', 2), item('d', 1)]), { wrapper });
|
||||
expect(three.result.current.map((d) => [d.n, d.ids])).toEqual([
|
||||
[1, ['b', 'd']],
|
||||
[2, ['c']],
|
||||
[3, ['a']],
|
||||
]);
|
||||
const two = renderHook(() => useWeekDays([item('a', 1), item('b', 2)]), { wrapper });
|
||||
expect(two.result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-006: a tap leaves a whole day out or takes it back in', () => {
|
||||
const onToggleDay = vi.fn();
|
||||
const days = [
|
||||
{ id: 1, n: 1, weekday: 'Mon', ids: ['a', 'b'] },
|
||||
{ id: 2, n: 2, weekday: 'Tue', ids: ['c'] },
|
||||
];
|
||||
render(<AssistantWeekOverview days={days} excluded={new Set(['c'])} disabled={false} onToggleDay={onToggleDay} />);
|
||||
const [mon, tue] = screen.getAllByRole('button');
|
||||
expect(mon).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(tue).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(mon);
|
||||
expect(onToggleDay).toHaveBeenCalledWith(['a', 'b'], false);
|
||||
fireEvent.click(tue);
|
||||
expect(onToggleDay).toHaveBeenLastCalledWith(['c'], true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the context menu rows', () => {
|
||||
it('FE-AI-POWER-007: none while the assistant cannot run; else they open the chat on the place and ask', () => {
|
||||
const send = vi.fn(async () => {});
|
||||
const openWith = vi.fn();
|
||||
useAssistantStore.setState({ status: null, send, openWith });
|
||||
const louvre = buildPlace({ id: 5, name: 'Louvre' });
|
||||
const off = renderHook(() => useAssistantMenu(TRIP));
|
||||
expect(off.result.current.place(louvre)).toEqual([]);
|
||||
|
||||
useAssistantStore.setState({ status: { available: true } as never });
|
||||
const on = renderHook(() => useAssistantMenu(TRIP));
|
||||
const rows = on.result.current.place(louvre);
|
||||
const alternative = rows.find((r) => 'label' in r && r.label === 'Find an alternative');
|
||||
act(() => (alternative && 'onClick' in alternative ? alternative.onClick() : undefined));
|
||||
expect(openWith).toHaveBeenCalledWith(TRIP, { placeIds: [5] });
|
||||
expect(send).toHaveBeenCalledWith(TRIP, expect.stringContaining('Louvre'));
|
||||
expect(on.result.current.day(3).length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the trip notes', () => {
|
||||
it('FE-AI-POWER-008: notes are kept per trip, and saving empty ones drops the entry', async () => {
|
||||
useAuthStore.setState({ user: buildUser({ id: 1 }) });
|
||||
const updateSetting = vi.fn(async () => {});
|
||||
useSettingsStore.setState({
|
||||
settings: { ...SETTINGS.settings, assistant_memory: { '2': 'Other' } },
|
||||
updateSetting,
|
||||
});
|
||||
const { result } = renderHook(() => useTripMemory(TRIP));
|
||||
expect(result.current.has).toBe(false);
|
||||
await act(async () => void (await result.current.save(' Vegetarian ')));
|
||||
expect(updateSetting).toHaveBeenCalledWith('assistant_memory', { '2': 'Other', [TRIP]: 'Vegetarian' });
|
||||
await act(async () => void (await result.current.save(' ')));
|
||||
expect(updateSetting).toHaveBeenLastCalledWith('assistant_memory', { '2': 'Other' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the map a question carries', () => {
|
||||
it('FE-AI-POWER-009: edges stay on the globe and are rounded to about ten metres', () => {
|
||||
expect(mapViewOf({ south: 35.123456, west: -190, north: 35.2, east: 139.987654 })).toEqual({
|
||||
south: 35.1235,
|
||||
west: -180,
|
||||
north: 35.2,
|
||||
east: 139.9877,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-010: a question goes out with the map of its own trip in its focus', async () => {
|
||||
const send = vi.fn();
|
||||
useAssistantStore.setState({ mapView: { tripId: TRIP, view: { south: 1, west: 2, north: 3, east: 4 } } });
|
||||
seedChat([]);
|
||||
// The request body is built inside the store; read it from the repo call.
|
||||
const { assistantRepo } = await import('../../repo/assistantRepo');
|
||||
const spy = vi.spyOn(assistantRepo, 'send').mockImplementation(async (_trip, body) => {
|
||||
send(body);
|
||||
throw new Error('stop');
|
||||
});
|
||||
await useAssistantStore.getState().send(TRIP, 'What is here?');
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ focus: { view: { south: 1, west: 2, north: 3, east: 4 } } })
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
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
|
||||
* stay below it, folded away until asked for.
|
||||
*/
|
||||
export default function AssistantWeekOverview({
|
||||
days,
|
||||
excluded,
|
||||
disabled,
|
||||
onToggleDay,
|
||||
}: {
|
||||
days: readonly WeekDay[];
|
||||
excluded: ReadonlySet<string>;
|
||||
disabled: boolean;
|
||||
onToggleDay: (ids: readonly string[], include: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('assistant.week.title')}
|
||||
className="mb-2 grid gap-1.5"
|
||||
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;
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={taken}
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{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 })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -196,6 +196,7 @@ function ChatWindow({
|
||||
))}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
|
||||
<AssistantHeader
|
||||
tripId={trip.id}
|
||||
thread={thread}
|
||||
tripTitle={trip.title}
|
||||
dragProps={frame.handleProps}
|
||||
|
||||
@@ -168,6 +168,20 @@
|
||||
animation: trek-assistant-busy 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
|
||||
@keyframes trek-assistant-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
}
|
||||
}
|
||||
.trek-assistant-pulse {
|
||||
animation: trek-assistant-pulse 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-assistant-pop,
|
||||
.trek-assistant-enter {
|
||||
@@ -176,7 +190,8 @@
|
||||
.trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
.trek-assistant-busy {
|
||||
.trek-assistant-busy,
|
||||
.trek-assistant-pulse {
|
||||
animation: none;
|
||||
}
|
||||
.trek-assistant-shimmer {
|
||||
@@ -196,7 +211,8 @@
|
||||
[data-reduce-motion] .trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-busy {
|
||||
[data-reduce-motion] .trek-assistant-busy,
|
||||
[data-reduce-motion] .trek-assistant-pulse {
|
||||
animation: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-shimmer {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AssistantMessage } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The next questions an answer suggests, as one tap each: read from what the
|
||||
* answer holds, not asked from the model, so they cost nothing and come at
|
||||
* once. Places found ask to narrow them, a plan asks to go on, a forecast asks
|
||||
* for the other weather. Three at most; the first kind the answer holds wins.
|
||||
*/
|
||||
|
||||
export interface FollowUp {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
const PLACES: readonly FollowUp[] = [
|
||||
{ id: 'cheaper', labelKey: 'assistant.followUp.cheaper' },
|
||||
{ id: 'closer', labelKey: 'assistant.followUp.closer' },
|
||||
{ id: 'openNow', labelKey: 'assistant.followUp.openNow' },
|
||||
];
|
||||
|
||||
const PLAN: readonly FollowUp[] = [
|
||||
{ id: 'nextDay', labelKey: 'assistant.followUp.nextDay' },
|
||||
{ id: 'relaxed', labelKey: 'assistant.followUp.relaxed' },
|
||||
{ id: 'dinners', labelKey: 'assistant.followUp.dinners' },
|
||||
];
|
||||
|
||||
const WEATHER: readonly FollowUp[] = [
|
||||
{ id: 'rain', labelKey: 'assistant.followUp.rain' },
|
||||
{ id: 'sun', labelKey: 'assistant.followUp.sun' },
|
||||
];
|
||||
|
||||
const GENERAL: readonly FollowUp[] = [
|
||||
{ id: 'more', labelKey: 'assistant.followUp.more' },
|
||||
{ id: 'next', labelKey: 'assistant.followUp.next' },
|
||||
];
|
||||
|
||||
/** What an answer offers to ask next; changes are offered only to someone who may make them. */
|
||||
export function followUpsFor(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] {
|
||||
const types = new Set(message.parts.map((part) => part.type));
|
||||
if (canEdit && (types.has('data-trek-change') || types.has('data-trek-applied'))) return PLAN;
|
||||
if (types.has('data-trek-places')) return PLACES;
|
||||
if (types.has('data-trek-weather')) return WEATHER;
|
||||
return types.has('text') ? GENERAL : [];
|
||||
}
|
||||
@@ -92,6 +92,10 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
|
||||
messageKey: 'assistant.error.maxSteps',
|
||||
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
|
||||
},
|
||||
empty_answer: {
|
||||
messageKey: 'assistant.error.emptyAnswer',
|
||||
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
|
||||
},
|
||||
offline: { messageKey: 'assistant.error.offline', action: null },
|
||||
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
|
||||
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-DAYAFTER-001 to FE-AI-DAYAFTER-008
|
||||
* FE-AI-DAYAFTER-001 to FE-AI-DAYAFTER-009
|
||||
*
|
||||
* A day as it would read once a card's waiting changes ran, without React: who
|
||||
* stays, who joins at the end and who leaves, the way between two stops as a
|
||||
@@ -10,7 +10,15 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
|
||||
import type { AssignmentsMap } from '../../types';
|
||||
import { buildDayAfter, changedDays, changePoints, type DayChange, minutesOf } from './dayAfterModel';
|
||||
import {
|
||||
buildDayAfter,
|
||||
changedDays,
|
||||
changePoints,
|
||||
type DayChange,
|
||||
dayIssues,
|
||||
minutesOf,
|
||||
stopIssue,
|
||||
} from './dayAfterModel';
|
||||
|
||||
// 0.01° of latitude is 1.112 km: with the detour factor that is 19 minutes on foot, 6 by bike.
|
||||
const louvre = buildPlace({ id: 10, name: 'Louvre', lat: 48.0, lng: 2.0, duration_minutes: 60 });
|
||||
@@ -51,7 +59,7 @@ describe('dayAfterModel', () => {
|
||||
]);
|
||||
expect(stops[0]?.lead).toBeNull();
|
||||
// 09:00 plus an hour, 19 minutes on foot, so 161 minutes are left before 13:00.
|
||||
expect(stops[1]?.lead).toEqual({ travelMinutes: 19, freeMinutes: 161, overlapsWith: null });
|
||||
expect(stops[1]?.lead).toEqual({ travelMinutes: 19, freeMinutes: 161, overlapsWith: null, shortBy: null });
|
||||
});
|
||||
|
||||
it('FE-AI-DAYAFTER-002: a new place joins at the end, named and placed by the change', () => {
|
||||
@@ -59,7 +67,7 @@ describe('dayAfterModel', () => {
|
||||
const { stops } = day(3, [change('c1', pantheon)]);
|
||||
const last = stops[stops.length - 1];
|
||||
expect(last).toMatchObject({ key: 'new:c1', name: 'Pantheon', status: 'new', toolCallId: 'c1', time: null });
|
||||
expect(last?.lead).toEqual({ travelMinutes: 19, freeMinutes: null, overlapsWith: null });
|
||||
expect(last?.lead).toEqual({ travelMinutes: 19, freeMinutes: null, overlapsWith: null, shortBy: null });
|
||||
});
|
||||
|
||||
it('FE-AI-DAYAFTER-003: a planned place joins by its id; a change with nothing to name it by is left out', () => {
|
||||
@@ -139,4 +147,15 @@ describe('dayAfterModel', () => {
|
||||
[48.02, 2.0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-AI-DAYAFTER-009: a way longer than the time between two stops is too tight, and counts as an issue', () => {
|
||||
// Louvre until 10:00, then 19 minutes on foot to Orsay, which starts at 10:10: nine minutes short.
|
||||
const tight = plan();
|
||||
tight[3] = (tight[3] ?? []).map((a) => (a.id === 101 ? { ...a, assignment_time: '10:10' } : a));
|
||||
const after = day(3, [], tight);
|
||||
expect(after.stops[1]?.lead).toMatchObject({ travelMinutes: 19, shortBy: 9 });
|
||||
expect(after.stops.map(stopIssue)).toEqual([false, true]);
|
||||
expect(dayIssues(after)).toBe(1);
|
||||
expect(dayIssues(day(3, []))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ interface StopLead {
|
||||
freeMinutes: number | null;
|
||||
/** The stop before, when the two take the same time. */
|
||||
overlapsWith: string | null;
|
||||
/** Minutes the way there is longer than the time between the two; null when it fits or the times do not say. */
|
||||
shortBy: number | null;
|
||||
}
|
||||
|
||||
export interface TimelineStop {
|
||||
@@ -152,10 +154,12 @@ function leadOf(prev: Draft, cur: Draft, defaultMode: string): StopLead {
|
||||
60
|
||||
)
|
||||
: null;
|
||||
if (cur.start === null || prev.end === null) return { travelMinutes, freeMinutes: null, overlapsWith: null };
|
||||
if (cur.start < prev.end) return { travelMinutes, freeMinutes: null, overlapsWith: prev.stop.name };
|
||||
const none = { freeMinutes: null, overlapsWith: null, shortBy: null };
|
||||
if (cur.start === null || prev.end === null) return { travelMinutes, ...none };
|
||||
if (cur.start < prev.end) return { travelMinutes, ...none, overlapsWith: prev.stop.name };
|
||||
const free = cur.start - prev.end - (travelMinutes ?? 0);
|
||||
return { travelMinutes, freeMinutes: free >= GAP_MIN_MINUTES ? free : null, overlapsWith: null };
|
||||
if (free < 0) return { travelMinutes, ...none, shortBy: -free };
|
||||
return { travelMinutes, ...none, freeMinutes: free >= GAP_MIN_MINUTES ? free : null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,3 +234,27 @@ export function changedDays(changes: readonly DayChange[]): number[] {
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/** A leg this long without a time says the day asks a lot of travel. */
|
||||
const FAR_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* Whether a day still works once the changes ran: stops that take the same
|
||||
* time, a way there longer than the time between two stops, and legs over an
|
||||
* hour. Counted over the stops that stay on the day.
|
||||
*/
|
||||
export function dayIssues(day: DayAfter): number {
|
||||
return day.stops.reduce((sum, stop) => {
|
||||
const lead = stop.lead;
|
||||
if (stop.status === 'removed' || !lead) return sum;
|
||||
const far = lead.travelMinutes !== null && lead.travelMinutes >= FAR_MINUTES;
|
||||
return sum + (lead.overlapsWith || lead.shortBy !== null || far ? 1 : 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Whether a stop is one of those `dayIssues` counts. */
|
||||
export function stopIssue(stop: TimelineStop): boolean {
|
||||
const lead = stop.lead;
|
||||
if (stop.status === 'removed' || !lead) return false;
|
||||
return !!lead.overlapsWith || lead.shortBy !== null || (lead.travelMinutes ?? 0) >= FAR_MINUTES;
|
||||
}
|
||||
|
||||
@@ -2,20 +2,19 @@ import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/**
|
||||
* Whether the trip map follows the assistant: pans to where it searches and
|
||||
* frames the places it suggests. On unless switched off. Behind the switch in
|
||||
* both AI settings shells, the desktop section and the phone card.
|
||||
*/
|
||||
export function useAssistantMapFollow(): { on: boolean; set: (on: boolean) => Promise<void> } {
|
||||
const on = useSettingsStore((s) => s.settings.assistant_map_follow) !== false;
|
||||
/** The assistant's own on/off settings; each reads as on unless switched off. */
|
||||
type AssistantFlag = 'assistant_map_follow' | 'assistant_cost_estimates';
|
||||
|
||||
/** One of the assistant's switches, read and saved through the user's settings. */
|
||||
function useAssistantFlag(key: AssistantFlag): { on: boolean; set: (on: boolean) => Promise<void> } {
|
||||
const on = useSettingsStore((s) => s.settings[key]) !== false;
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
const set = async (next: boolean) => {
|
||||
try {
|
||||
await updateSetting('assistant_map_follow', next);
|
||||
await updateSetting(key, next);
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : t('common.error'));
|
||||
}
|
||||
@@ -23,3 +22,21 @@ export function useAssistantMapFollow(): { on: boolean; set: (on: boolean) => Pr
|
||||
|
||||
return { on, set };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the trip map follows the assistant: pans to where it searches and
|
||||
* frames the places it suggests. On unless switched off. Behind the switch in
|
||||
* both AI settings shells, the desktop section and the phone card.
|
||||
*/
|
||||
export function useAssistantMapFollow() {
|
||||
return useAssistantFlag('assistant_map_follow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the assistant proposes an estimated budget item along with a place
|
||||
* that costs money, as one more change card to apply or leave. On unless
|
||||
* switched off; the server reads the same setting.
|
||||
*/
|
||||
export function useAssistantCostEstimates() {
|
||||
return useAssistantFlag('assistant_cost_estimates');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AssistantFocus } from '@trek/shared';
|
||||
import { CalendarCheck, CloudRain, type LucideIcon, Shuffle, Sparkles, UtensilsCrossed } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import type { Place } from '../../types';
|
||||
|
||||
/** One row of a context menu, or the line before the assistant's rows. */
|
||||
type AssistantMenuEntry = { label: string; icon: LucideIcon; onClick: () => void } | { divider: true };
|
||||
|
||||
/**
|
||||
* The assistant's rows in the planner's context menus, so a question starts
|
||||
* where the user already is: a right click on a place asks about it, finds
|
||||
* something else in its place, or a restaurant near it; one on a day plans it
|
||||
* or makes a plan for rain. Each opens the chat with the place or day as its
|
||||
* focus and sends the question at once. Empty while the assistant cannot run
|
||||
* for this user, so the menus look as they always did.
|
||||
*/
|
||||
export function useAssistantMenu(tripId: number | null) {
|
||||
const { t } = useTranslation();
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const openWith = useAssistantStore((s) => s.openWith);
|
||||
const send = useAssistantStore((s) => s.send);
|
||||
const on = available && tripId !== null;
|
||||
|
||||
const ask = (focus: AssistantFocus, text?: string) => {
|
||||
if (tripId === null) return;
|
||||
openWith(tripId, focus);
|
||||
if (text) void send(tripId, text);
|
||||
};
|
||||
|
||||
const place = (target: Place): AssistantMenuEntry[] => {
|
||||
if (!on) return [];
|
||||
const focus = { placeIds: [target.id] };
|
||||
return [
|
||||
{ divider: true },
|
||||
{ label: t('assistant.context.ask'), icon: Sparkles, onClick: () => ask(focus) },
|
||||
{
|
||||
label: t('assistant.menu.alternative'),
|
||||
icon: Shuffle,
|
||||
onClick: () => ask(focus, t('assistant.menu.alternativeAsk', { place: target.name })),
|
||||
},
|
||||
{
|
||||
label: t('assistant.menu.foodNearby'),
|
||||
icon: UtensilsCrossed,
|
||||
onClick: () => ask(focus, t('assistant.menu.foodNearbyAsk', { place: target.name })),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const day = (dayId: number): AssistantMenuEntry[] => {
|
||||
if (!on) return [];
|
||||
const focus = { dayId };
|
||||
return [
|
||||
{ divider: true },
|
||||
{
|
||||
label: t('assistant.menu.planDay'),
|
||||
icon: CalendarCheck,
|
||||
onClick: () => ask(focus, t('assistant.menu.planDayAsk')),
|
||||
},
|
||||
{
|
||||
label: t('assistant.menu.rainPlan'),
|
||||
icon: CloudRain,
|
||||
onClick: () => ask(focus, t('assistant.menu.rainPlanAsk')),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return { place, day };
|
||||
}
|
||||
@@ -22,6 +22,14 @@ interface StripDay {
|
||||
/** Its place in the trip, 1 for the first day: "+ Day 3". */
|
||||
n: number;
|
||||
label: string;
|
||||
/** Its weekday, short ("Sat"), for the day grid; null for a day without a date. */
|
||||
weekday: string | null;
|
||||
}
|
||||
|
||||
/** The short weekday of a `YYYY-MM-DD` date, read as that calendar day wherever the reader is. */
|
||||
function weekdayOf(date: string | null | undefined, locale: string): string | null {
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
return new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`));
|
||||
}
|
||||
|
||||
/** The trip category a found place most likely belongs in, for its line and its icon. */
|
||||
@@ -89,7 +97,12 @@ export function useAssistantPlaceStrip() {
|
||||
|
||||
const stripDays = useMemo(
|
||||
(): StripDay[] =>
|
||||
(days ?? []).map((day, index) => ({ id: day.id, n: index + 1, label: dayLabel(day, index, t, locale) })),
|
||||
(days ?? []).map((day, index) => ({
|
||||
id: day.id,
|
||||
n: index + 1,
|
||||
label: dayLabel(day, index, t, locale),
|
||||
weekday: weekdayOf(day.date, locale),
|
||||
})),
|
||||
[days, t, locale]
|
||||
);
|
||||
const focusDay = stripDays.find((d) => d.id === focusDayId) ?? null;
|
||||
|
||||
@@ -7,6 +7,16 @@ import { useAuthStore } from '../../store/authStore';
|
||||
/** The only pictures a found place shows: TREK's own photo proxy, never an address from the search. */
|
||||
const PROXY_PREFIX = '/api/maps/place-photo/';
|
||||
|
||||
/**
|
||||
* A key the proxy can find this very place's photo by. A place known only by
|
||||
* its position (`coords:`, what a search of the open map data finds) would get
|
||||
* a picture of its surroundings, the same street scene for every restaurant
|
||||
* in a district; such a card shows its category instead.
|
||||
*/
|
||||
function photoKeyOf(place: AssistantFoundPlace): string | null {
|
||||
return place.photoKey && !place.photoKey.startsWith('coords:') ? place.photoKey : null;
|
||||
}
|
||||
|
||||
/** A picture TREK's proxy serves, or null for anything else. */
|
||||
function proxiedPhoto(url: string | null | undefined): string | null {
|
||||
return url?.startsWith(PROXY_PREFIX) ? url : null;
|
||||
@@ -18,11 +28,12 @@ function proxiedPhoto(url: string | null | undefined): string | null {
|
||||
* provider, and a strip holds several. The answer goes through the shared
|
||||
* photo cache and its limit on parallel requests, so a place the planner shows
|
||||
* too is fetched once. Null while it loads, when there is none, when photos
|
||||
* are off, and for any answer that is not the proxy's own address.
|
||||
* are off, for a place known only by its position, and for any answer that is
|
||||
* not the proxy's own address.
|
||||
*/
|
||||
export function useFoundPlacePhoto(place: AssistantFoundPlace, ref: RefObject<Element | null>): string | null {
|
||||
const enabled = useAuthStore((s) => s.placesPhotosEnabled);
|
||||
const key = place.photoKey;
|
||||
const key = photoKeyOf(place);
|
||||
const [src, setSrc] = useState<string | null>(() => (key ? proxiedPhoto(getCached(key)?.photoUrl) : null));
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/** As long as the server reads a trip's notes; more is cut there. */
|
||||
export const MAX_MEMORY_CHARS = 2_000;
|
||||
|
||||
/**
|
||||
* What the user keeps for the assistant to mind on one trip ("vegetarian, no
|
||||
* museums"): read from and saved to their settings, one entry per trip, so it
|
||||
* follows them to every device. Every question on the trip carries it. The
|
||||
* same for the window and the phone.
|
||||
*/
|
||||
export function useTripMemory(tripId: number) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const all = useSettingsStore((s) => s.settings.assistant_memory);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const text = all?.[String(tripId)] ?? '';
|
||||
|
||||
/** Saves the notes; empty ones are dropped rather than kept as a blank entry. */
|
||||
const save = async (next: string): Promise<boolean> => {
|
||||
const trimmed = next.trim().slice(0, MAX_MEMORY_CHARS);
|
||||
const rest = { ...(all ?? {}) };
|
||||
if (trimmed) rest[String(tripId)] = trimmed;
|
||||
else delete rest[String(tripId)];
|
||||
try {
|
||||
await updateSetting('assistant_memory', rest);
|
||||
toast.success(t('assistant.memory.saved'));
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : t('common.error'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return { text, has: text.trim().length > 0, save };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { ChangeItem } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
|
||||
/** The day a waiting change lands on: where its effect puts it, else the day its card names. */
|
||||
function dayOf(item: ChangeItem): number | null {
|
||||
return item.preview?.effect?.dayId ?? item.preview?.dayId ?? null;
|
||||
}
|
||||
|
||||
export interface WeekDay {
|
||||
id: number;
|
||||
n: number;
|
||||
weekday: string | null;
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
/** A plan this wide reads as a week: the overview shows from three days on. */
|
||||
const WEEK_FROM_DAYS = 3;
|
||||
|
||||
/**
|
||||
* The days a card's waiting changes land on, in trip order, each with its
|
||||
* changes; empty for a plan narrower than a week view.
|
||||
*/
|
||||
export function useWeekDays(pending: readonly ChangeItem[]): WeekDay[] {
|
||||
const { locale } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const days = useTripStore((s) => (s.trip?.id === tripId ? s.days : null));
|
||||
return useMemo(() => {
|
||||
if (!days) return [];
|
||||
const byDay = new Map<number, string[]>();
|
||||
for (const item of pending) {
|
||||
const id = dayOf(item);
|
||||
if (id !== null) byDay.set(id, [...(byDay.get(id) ?? []), item.toolCallId]);
|
||||
}
|
||||
if (byDay.size < WEEK_FROM_DAYS) return [];
|
||||
const weekdayOf = (date: string | null | undefined) =>
|
||||
date && /^\d{4}-\d{2}-\d{2}$/.test(date)
|
||||
? new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`))
|
||||
: null;
|
||||
return days.flatMap((day, index) => {
|
||||
const ids = byDay.get(day.id);
|
||||
return ids ? [{ id: day.id, n: index + 1, weekday: weekdayOf(day.date), ids }] : [];
|
||||
});
|
||||
}, [days, pending, locale]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { useAssistantMarkerClasses } from './assistantCamera';
|
||||
import { AssistantCameraController } from './AssistantCameraController';
|
||||
import { AssistantGhostLayer } from './AssistantGhostLayer';
|
||||
import { useReportViewLeaflet } from './assistantViewport';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
|
||||
interface AssistantMapLeafletProps {
|
||||
@@ -15,10 +16,12 @@ interface AssistantMapLeafletProps {
|
||||
/**
|
||||
* Everything the assistant does on the Leaflet map, mounted inside the map
|
||||
* container as one child, so MapView only hands its props on: the ghosts,
|
||||
* the camera, and the pop and pulse on the real pins.
|
||||
* the camera, the pop and pulse on the real pins, and the part of the map
|
||||
* the assistant is told is on screen.
|
||||
*/
|
||||
export function AssistantMapLeaflet({ assistant, padding, markerEl }: AssistantMapLeafletProps) {
|
||||
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
|
||||
useReportViewLeaflet();
|
||||
return (
|
||||
<>
|
||||
<AssistantGhostLayer
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AssistantMapView } from '@trek/shared';
|
||||
import { useEffect } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
|
||||
/**
|
||||
* The part of the planner's map on screen, told to the assistant after every
|
||||
* move, so a question about "here" needs no place named. Both renderers report
|
||||
* through this module; the assistant sends it with the next question only for
|
||||
* the trip it was taken on.
|
||||
*/
|
||||
|
||||
interface Edges {
|
||||
south: number;
|
||||
west: number;
|
||||
north: number;
|
||||
east: number;
|
||||
}
|
||||
|
||||
const clamp = (value: number, limit: number) => Math.max(-limit, Math.min(limit, value));
|
||||
const round = (value: number) => Math.round(value * 1e4) / 1e4;
|
||||
|
||||
/** The edges as the contract takes them: inside the globe (a wrapped map runs past ±180) and to about ten metres. */
|
||||
export function mapViewOf(edges: Edges): AssistantMapView {
|
||||
return {
|
||||
south: round(clamp(edges.south, 90)),
|
||||
west: round(clamp(edges.west, 180)),
|
||||
north: round(clamp(edges.north, 90)),
|
||||
east: round(clamp(edges.east, 180)),
|
||||
};
|
||||
}
|
||||
|
||||
function report(tripId: number | null, edges: Edges | null): void {
|
||||
const set = useAssistantStore.getState().setMapView;
|
||||
// A map without a size yet (hidden, or still laying out) has no edges to speak of.
|
||||
const usable = edges && Object.values(edges).every(Number.isFinite) && edges.north > edges.south;
|
||||
set(tripId !== null && usable ? { tripId, view: mapViewOf(edges) } : null);
|
||||
}
|
||||
|
||||
/** Leaflet: read from inside the map container, after each move and once on mount. */
|
||||
export function useReportViewLeaflet(): void {
|
||||
const map = useMap();
|
||||
const tripId = useTripStore((s) => s.trip?.id ?? null);
|
||||
useEffect(() => {
|
||||
const read = () => {
|
||||
const b = map.getBounds();
|
||||
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
|
||||
};
|
||||
read();
|
||||
map.on('moveend', read);
|
||||
return () => {
|
||||
map.off('moveend', read);
|
||||
report(null, null);
|
||||
};
|
||||
}, [map, tripId]);
|
||||
}
|
||||
|
||||
/** What the GL map offers for it; mapbox-gl and maplibre-gl alike. */
|
||||
interface ViewMap {
|
||||
getBounds: () => {
|
||||
getSouth: () => number;
|
||||
getWest: () => number;
|
||||
getNorth: () => number;
|
||||
getEast: () => number;
|
||||
} | null;
|
||||
on: (type: 'moveend', listener: () => void) => unknown;
|
||||
off: (type: 'moveend', listener: () => void) => unknown;
|
||||
}
|
||||
|
||||
/** GL: the same, once the map is ready. */
|
||||
export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady: boolean): void {
|
||||
const tripId = useTripStore((s) => s.trip?.id ?? null);
|
||||
useEffect(() => {
|
||||
const map = mapRef.current as Partial<ViewMap> | null;
|
||||
if (!mapReady || !map?.on || !map.off || !map.getBounds) return;
|
||||
const { on, off, getBounds } = { on: map.on.bind(map), off: map.off.bind(map), getBounds: map.getBounds.bind(map) };
|
||||
const read = () => {
|
||||
const b = getBounds();
|
||||
if (typeof b?.getSouth !== 'function') return;
|
||||
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
|
||||
};
|
||||
read();
|
||||
on('moveend', read);
|
||||
return () => {
|
||||
off('moveend', read);
|
||||
report(null, null);
|
||||
};
|
||||
}, [mapRef, mapReady, tripId]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { useAssistantMarkerClasses } from './assistantCamera';
|
||||
import { PLACE_POP_CLASS, PLACE_PULSE_CLASS } from './assistantGhostMarker';
|
||||
import { useReportViewGL } from './assistantViewport';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
import { useAssistantCameraGL, type CameraEngine, type CameraMap } from './useAssistantCameraGL';
|
||||
import { useAssistantGhostMarkersGL, type GhostMap } from './useAssistantGhostMarkersGL';
|
||||
@@ -65,4 +66,5 @@ export function useAssistantMapGL<Pin extends PinGL>({
|
||||
useAssistantCameraGL({ mapRef, mapReady, gl, camera: assistant.assistantCamera, padding });
|
||||
const markerEl = useCallback((id: number) => markersRef.current.get(id)?.el ?? null, [markersRef]);
|
||||
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
|
||||
useReportViewGL(mapRef, mapReady);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import GoogleMapsIcon from '../shared/GoogleMapsIcon'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import { useAssistantMenu } from '../Assistant/useAssistantMenu'
|
||||
import AssistantDayPreview from '../Assistant/AssistantDayPreview'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
@@ -1252,16 +1254,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
isMobile,
|
||||
} = withDayPlanDefaults(props)
|
||||
const onClearDay = props.onClearDay
|
||||
// Below lg the plan and the places sit in separate tabs, so there is nothing
|
||||
// to drag between. A coarse pointer is no longer a reason of its own: tablets
|
||||
// reach the same drag through a long press (#1616).
|
||||
// Below lg plan and places sit in separate tabs; tablets drag by long press (#1616).
|
||||
const dragDisabled = isMobile
|
||||
const ctxMenu = useContextMenu()
|
||||
const ai = useAssistantMenu(tripId)
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const can = useCanDo()
|
||||
const canEditDays = can('day_edit', trip)
|
||||
// Editing or deleting the place itself is a place right; taking it off the
|
||||
// day stays a day right (#2446).
|
||||
// Editing or deleting the place is a place right; taking it off the day a day right (#2446).
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
// The calendar subscription hands out a link that reads the trip without an
|
||||
// account, so it sits behind the same permission as the public share link.
|
||||
@@ -1578,7 +1578,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
].filter(Boolean)
|
||||
// Right-click on the head: what can be added, and emptying the day (#2470).
|
||||
const dayMenuItems = [
|
||||
...addItems,
|
||||
...addItems, ...ai.day(day.id),
|
||||
...(onClearDay && da.length > 0 ? [
|
||||
{ divider: true },
|
||||
{ label: t('dayplan.clearDay'), icon: Eraser, danger: true, onClick: () => onClearDay(day.id) },
|
||||
@@ -1708,8 +1708,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<div
|
||||
data-dp="day-body"
|
||||
className="border-t border-edge-faint px-1.5 pb-1.5 pt-1.5"
|
||||
// The activity list — the largest region and the one behind the
|
||||
// densest text, so its tint is the faintest of the three.
|
||||
// The activity list, the largest region behind the densest text: the faintest tint.
|
||||
style={dayBodyStyle(dayTintBackground(dayTint, 'activity', '--day-tint-activity'))}
|
||||
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
@@ -1870,7 +1869,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
},
|
||||
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
|
||||
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => openSaveToCollection(place, !!isTourPlace, tripId) },
|
||||
{ divider: true },
|
||||
...ai.place(place), { divider: true },
|
||||
canEditPlaces &&
|
||||
onDeletePlace &&
|
||||
!(toursEnabled && (props.tourPlaceIds?.has(place.id) || place.tour_place_id === place.id)) && {
|
||||
@@ -2530,7 +2529,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</Tooltip>
|
||||
) : connector
|
||||
})()}
|
||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||
<AssistantDayPreview tripId={tripId} dayId={day.id} />{/* The assistant's waiting changes, then the drop zone at the list end */}
|
||||
<div
|
||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { placeMatchesSearch } from '../../utils/placeSearch'
|
||||
import { matchesCategoryFilter, matchesPlacesFilter } from '../../utils/placesFilter'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
import { useAssistantMenu } from '../Assistant/useAssistantMenu'
|
||||
import type { MenuEntry } from './planParts'
|
||||
import { useListImport, type ListImportProvider } from './useListImport'
|
||||
import { usePlacesPool, type PlacesFilter } from './usePlacesPool'
|
||||
@@ -93,6 +94,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const ctxMenu = useContextMenu()
|
||||
const ai = useAssistantMenu(tripId)
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
// A booking plans the place it points at, so the pool has to see them (#2072).
|
||||
const reservations = useTripStore((s) => s.reservations)
|
||||
@@ -276,11 +278,12 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
!!website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(website, '_blank', 'noopener,noreferrer') },
|
||||
!!googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
|
||||
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
|
||||
...ai.place(place),
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
]
|
||||
return entries.filter((entry): entry is MenuEntry => entry !== false)
|
||||
}, [canEditPlaces, collectionsEnabled, t, onEditPlace, onAssignToDay, onDeletePlace])
|
||||
}, [canEditPlaces, collectionsEnabled, t, onEditPlace, onAssignToDay, onDeletePlace, ai])
|
||||
|
||||
// The day is read when the menu opens, so it stays stable across day switches.
|
||||
const openCtxMenu = ctxMenu.open
|
||||
|
||||
@@ -37,8 +37,10 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
own key, and there nobody does. */}
|
||||
{/* The assistant in TREK, with the same managed rule as AI parsing: there the
|
||||
operator decides which model the install talks to. */}
|
||||
{S.aiAssistantEnabled && !managed && <AiConnectionSection />}
|
||||
{S.llmEnabled && !managed && <LlmConnectionSection />}
|
||||
{S.aiConnectionEnabled && !managed && (
|
||||
<AiConnectionSection assistant={S.aiAssistantEnabled} parsing={S.llmEnabled} />
|
||||
)}
|
||||
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
|
||||
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
|
||||
one should not have to read past a section about AI assistants. */}
|
||||
<ApiKeysSection />
|
||||
@@ -54,11 +56,11 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
function useIntegrations() {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
|
||||
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
|
||||
|
||||
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
|
||||
|
||||
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, dawarichEnabled,
|
||||
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
|
||||
wandererEnabled,
|
||||
managed, ...mcp,
|
||||
}
|
||||
|
||||
@@ -2,23 +2,23 @@ import type { AiConnectionKind, AiConnectionView } from '@trek/shared';
|
||||
import { Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
|
||||
import Section from '../Section';
|
||||
import {
|
||||
SettingRow,
|
||||
SettingRows,
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_BUTTON_DANGER,
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SettingRow,
|
||||
SettingRows,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from '../settingsKit';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { aiSectionText, CONNECTION_STATUS } from './aiConnectModel';
|
||||
import AiConnectPanel, { ProviderMark } from './AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from './aiConnectModel';
|
||||
import { useAiConnections } from './useAiConnections';
|
||||
|
||||
type AiConnections = ReturnType<typeof useAiConnections>;
|
||||
@@ -173,6 +173,7 @@ function InstanceConnection({ S }: { S: AiConnections }) {
|
||||
function AssistantBehaviour() {
|
||||
const { t } = useTranslation();
|
||||
const follow = useAssistantMapFollow();
|
||||
const costs = useAssistantCostEstimates();
|
||||
return (
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
@@ -186,26 +187,45 @@ function AssistantBehaviour() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('assistant.settings.costEstimates')}
|
||||
hint={t('assistant.settings.costEstimatesHint')}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={costs.on}
|
||||
onToggle={() => void costs.set(!costs.on)}
|
||||
label={t('assistant.settings.costEstimates')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Integrations → AI assistant: connect a provider, see which one
|
||||
* answers, pick its model, leave again. The only place a user sets the
|
||||
* assistant up; once it works, one button takes them to it.
|
||||
* Settings → Integrations → the user's AI connection: connect a provider, see
|
||||
* which one answers, pick its model, leave again. The assistant runs on it and
|
||||
* AI parsing reads bookings with it, so it shows while either addon is on and
|
||||
* says which it serves.
|
||||
*/
|
||||
export default function AiConnectionSection(): React.ReactElement | null {
|
||||
export default function AiConnectionSection({
|
||||
assistant = true,
|
||||
parsing = false,
|
||||
}: {
|
||||
assistant?: boolean;
|
||||
parsing?: boolean;
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const S = useAiConnections();
|
||||
const text = aiSectionText(assistant, parsing);
|
||||
if (S.view === 'hidden') return null;
|
||||
const own = S.own;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t('assistant.connect.title')}
|
||||
title={t(text.titleKey)}
|
||||
icon={Sparkles}
|
||||
hint={t('assistant.connect.description')}
|
||||
hint={t(text.hintKey)}
|
||||
badge={
|
||||
own ? (
|
||||
<StatusPill tone={CONNECTION_STATUS[own.status].tone}>{t(CONNECTION_STATUS[own.status].labelKey)}</StatusPill>
|
||||
@@ -219,7 +239,7 @@ export default function AiConnectionSection(): React.ReactElement | null {
|
||||
)}
|
||||
{S.view === 'own' && own && <OwnConnection S={S} own={own} />}
|
||||
{S.view === 'instance' && <InstanceConnection S={S} />}
|
||||
{(S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
|
||||
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
|
||||
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
|
||||
|
||||
{S.panel && (
|
||||
|
||||
@@ -121,3 +121,15 @@ export function httpHostOf(raw: string): string {
|
||||
export function svgDataUrl(svg: string): string {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a user's AI connection serves, as its section's title and line: the
|
||||
* assistant, AI parsing (booking import reads with the same connection), or both.
|
||||
*/
|
||||
export function aiSectionText(assistant: boolean, parsing: boolean): { titleKey: string; hintKey: string } {
|
||||
if (!assistant) return { titleKey: 'settings.aiParsing.title', hintKey: 'assistant.connect.descriptionParsingOnly' };
|
||||
return {
|
||||
titleKey: 'assistant.connect.title',
|
||||
hintKey: parsing ? 'assistant.connect.descriptionWithParsing' : 'assistant.connect.description',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
/**
|
||||
* Which integrations the settings shells offer: the addon flags behind each
|
||||
@@ -22,6 +23,11 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
|
||||
const dawarichEnabled = addonEnabled('dawarich');
|
||||
// The wanderer connection feeds the Tours workspace, while the admin allows it there.
|
||||
const wandererEnabled = useAddonStore((s) => s.toursWanderer);
|
||||
// The AI connection serves the assistant and AI parsing alike.
|
||||
const aiConnectionEnabled = aiAssistantEnabled || llmEnabled;
|
||||
// A personal parsing model saved before parsing read with the AI connection;
|
||||
// its form stays for whoever has one, and is no longer offered to anyone else.
|
||||
const ownParsing = useSettingsStore((s) => !!(s.settings.llm_provider && s.settings.llm_model));
|
||||
const hasIntegrations =
|
||||
memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || aiAssistantEnabled || dawarichEnabled;
|
||||
|
||||
@@ -35,6 +41,8 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
|
||||
airtrailEnabled,
|
||||
llmEnabled,
|
||||
aiAssistantEnabled,
|
||||
aiConnectionEnabled,
|
||||
ownParsing,
|
||||
dawarichEnabled,
|
||||
wandererEnabled,
|
||||
hasIntegrations,
|
||||
|
||||
@@ -3,28 +3,25 @@ import { useTranslation } from '../../../i18n'
|
||||
import { useIsDark } from '../../../hooks/useIsDark'
|
||||
import {
|
||||
Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Check, Loader2, Mountain, CarFront,
|
||||
Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Settings2, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../components/shared/DocumentProviderIcons'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import { LLM_VISION_MODES } from '@trek/shared'
|
||||
import {
|
||||
type Addon,
|
||||
type CollabFeatures,
|
||||
type ProviderOption,
|
||||
ANTHROPIC_HINT,
|
||||
COLLAB_SUB_FEATURES,
|
||||
getAddonLabel,
|
||||
MASKED,
|
||||
RECOMMENDED_MODELS,
|
||||
} from '../../../components/Admin/addons/addonModel'
|
||||
import { useAddonManager } from '../../../components/Admin/addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from '../../../components/Admin/addons/useLlmParsingConfig'
|
||||
import MAdminAiConfig from './MAdminAiConfig'
|
||||
import AiSettingsDialog from '../../../components/Admin/addons/AiAdminConfig'
|
||||
import { AI_ADDON_ICONS, type AiAddonId } from '../../../components/Admin/addons/aiAddons'
|
||||
import { useAiAddonTile } from '../../../components/Admin/addons/useAiAddonTile'
|
||||
import { ImmichIcon, SynologyIcon } from '../../../components/Admin/addons/PhotoProviderIcons'
|
||||
import { MAdminButton, MAdminCard, MAdminClampedText, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
|
||||
import { MAdminCard, MAdminClampedText } from './MAdminUi'
|
||||
import WandererMark from '../../../components/Tours/wanderer/WandererMark'
|
||||
|
||||
// Keys are the `icon` column from the addons table; anything unknown falls back to
|
||||
@@ -64,7 +61,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
const { t } = useTranslation()
|
||||
const dark = useIsDark()
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
@@ -170,24 +167,13 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
</MAdminCard>
|
||||
)}
|
||||
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
{/* Integration Addons, headed by the AI ones as one group, as on the desktop */}
|
||||
{(integrationAddons.length > 0 || aiAddons.length > 0) && (
|
||||
<MAdminCard>
|
||||
<MGroupHead icon={Link2} label={t('admin.addons.type.integration')} hint={t('admin.addons.integrationHint')} />
|
||||
{aiAddons.length > 0 && <MAiAddons addons={aiAddons} onToggle={handleToggle} />}
|
||||
{integrationAddons.map((addon, i) => (
|
||||
<div key={addon.id}>
|
||||
<MAddonRow addon={addon} onToggle={handleToggle} t={t} first={i === 0} />
|
||||
{addon.id === 'llm_parsing' && addon.enabled && (
|
||||
<div className="mt-1 border-t border-[color:var(--m-rowbr)] pt-3">
|
||||
<LlmParsingConfig addon={addon} />
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'ai_assistant' && addon.enabled && (
|
||||
<div className="mt-1 border-t border-[color:var(--m-rowbr)] pt-3">
|
||||
<MAdminAiConfig />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MAddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} first={i === 0 && aiAddons.length === 0} />
|
||||
))}
|
||||
</MAdminCard>
|
||||
)}
|
||||
@@ -285,209 +271,50 @@ function MSubRow({ icon: Icon, providerIcon: ProviderIcon, title, subtitle, enab
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
/** The AI addons as one group: a head row, their switches, and the button to the settings dialog. */
|
||||
function MAiAddons({ addons, onToggle }: { addons: Addon[]; onToggle: (addon: Addon) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
|
||||
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
|
||||
} = useLlmParsingConfig(addon)
|
||||
|
||||
const sectionCls = 'font-geist text-[0.625rem] font-bold uppercase tracking-[0.06em] text-m-faint'
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
|
||||
const tile = useAiAddonTile(addons)
|
||||
return (
|
||||
<div className="space-y-5 ps-[50px]">
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">
|
||||
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Connection</div>
|
||||
<MAdminField label="Provider">
|
||||
<div className="space-y-[6px]">
|
||||
{providerOptions.map((opt) => {
|
||||
const active = provider === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setProvider(opt.value)}
|
||||
className={`flex w-full items-center gap-2 rounded-xl border px-3 py-[10px] text-start ${
|
||||
active ? 'border-[color:var(--m-act)] bg-[color:var(--m-ic)]' : 'border-[color:var(--m-rowbr)]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-none text-m-muted">{opt.icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{opt.label}</span>
|
||||
{opt.badge && (
|
||||
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
|
||||
{opt.badge}
|
||||
</span>
|
||||
)}
|
||||
{active && <Check size={15} strokeWidth={2.4} className="flex-none text-m-ink" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</MAdminField>
|
||||
<MAdminField label="Base URL">
|
||||
<MAdminInput
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
value={baseUrl}
|
||||
onChange={e => setBaseUrl(e.target.value)}
|
||||
onBlur={loadModels}
|
||||
placeholder={baseUrlPlaceholder}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 py-[11px]">
|
||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Sparkles size={18} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-bold text-m-ink">{t('admin.addons.ai.name')}</span>
|
||||
<p className="mt-[1px] font-geist text-[0.625rem] leading-relaxed text-m-muted">{t('admin.addons.ai.description')}</p>
|
||||
</div>
|
||||
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
|
||||
{tile.enabledCount}/{addons.length}
|
||||
</span>
|
||||
</div>
|
||||
{addons.map(addon => {
|
||||
const label = getAddonLabel(t, addon)
|
||||
return (
|
||||
<MSubRow
|
||||
key={addon.id}
|
||||
icon={AI_ADDON_ICONS[addon.id as AiAddonId]}
|
||||
title={label.name}
|
||||
subtitle={label.description}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label="API key">
|
||||
<MAdminSecretInput
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'}
|
||||
/>
|
||||
</MAdminField>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">{ANTHROPIC_HINT}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Model */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<MAdminInput
|
||||
autoComplete="off"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'}
|
||||
/>
|
||||
|
||||
<MAdminField label={t('settings.aiParsing.multimodal')}>
|
||||
<div role="radiogroup" aria-label={t('settings.aiParsing.multimodal')} className="flex gap-[6px]">
|
||||
{LLM_VISION_MODES.map((mode) => {
|
||||
const active = vision === mode
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setVision(mode)}
|
||||
className={`flex-1 rounded-xl border px-3 py-[10px] text-[0.8125rem] font-semibold text-m-ink ${
|
||||
active ? 'border-[color:var(--m-act)] bg-[color:var(--m-ic)]' : 'border-[color:var(--m-rowbr)]'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.addons.llm.vision.${mode}`)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</MAdminField>
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">
|
||||
{t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
|
||||
</p>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[0.75rem] font-semibold text-m-ink">Installed on the server</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadModels}
|
||||
disabled={loadingModels}
|
||||
className="font-geist text-[0.6875rem] text-m-muted underline disabled:opacity-60"
|
||||
>
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="font-geist text-[0.6875rem] text-[color:var(--m-st-danger)]">{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="font-geist text-[0.6875rem] text-m-faint">No models installed yet — pull one below.</p>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-[10px] py-[5px] text-[0.6875rem] ${
|
||||
model === name ? 'border-transparent bg-m-act text-m-actfg' : 'border-[color:var(--m-rowbr)] text-m-ink'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-[color:var(--m-rowbr)] pt-3">
|
||||
<div className="mb-2 text-[0.75rem] font-semibold text-m-ink">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex items-center gap-3 rounded-xl border px-3 py-2 ${
|
||||
active ? 'border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)]' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[0.8125rem] text-m-ink">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="rounded-md bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_15%,transparent)] px-1.5 py-px font-geist text-[0.5625rem] font-bold text-[color:var(--m-st-confirmed)]">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-geist text-[0.625rem] leading-relaxed text-m-faint">{m.note}</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1.5">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[color:var(--m-ic)]">
|
||||
<div className="h-full bg-m-act transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 font-geist text-[0.5625rem] text-m-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{installedHere ? (
|
||||
<MAdminButton variant="ghost" disabled={active} onClick={() => setModel(m.id)}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</MAdminButton>
|
||||
) : (
|
||||
<MAdminButton busy={isPulling} disabled={!!pulling} onClick={() => pull(m.id)}>
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</MAdminButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<MAdminButton busy={saving} onClick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</MAdminButton>
|
||||
)
|
||||
})}
|
||||
{tile.canSetUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tile.openDialog}
|
||||
className="flex w-full items-center gap-3 border-t border-[color:var(--m-rowbr)] py-[11px] ps-[50px] text-start"
|
||||
>
|
||||
<Settings2 size={14} className="flex-none text-m-faint" />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.75rem] font-bold text-m-ink">{t('admin.addons.ai.setup')}</span>
|
||||
<ChevronRight size={15} className="flex-none text-m-faint rtl:rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// FE-MOB-AI-ADMIN-001 to FE-MOB-AI-ADMIN-002: the AI assistant's form under its tile on the phone.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { AiAdminSettings } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
adminSettings: vi.fn(),
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
|
||||
const { default: MAdminAiConfig } = await import('./MAdminAiConfig');
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance', dailyTurns: 25, retention: { mode: 'days', days: 30 } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
repo.adminConnection.mockResolvedValue({
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'gemini',
|
||||
model: 'gemini-flash',
|
||||
hint: '…77aa',
|
||||
status: 'ok',
|
||||
host: 'generativelanguage.googleapis.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
});
|
||||
repo.adminModels.mockResolvedValue([
|
||||
{ id: 'gemini-flash', label: 'Gemini Flash', supportsTools: true },
|
||||
{ id: 'gemini-pro', label: 'Gemini Pro', supportsTools: true },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('MAdminAiConfig', () => {
|
||||
it('FE-MOB-AI-ADMIN-001: shows the policy, the live connection with its models, the limit and the retention', async () => {
|
||||
render(<MAdminAiConfig />);
|
||||
expect(await screen.findByRole('radio', { name: 'I pay for everyone' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Gemini')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('option', { name: 'Gemini Pro' })).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue('25')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: 'Keep chats' })).toHaveValue('30');
|
||||
});
|
||||
|
||||
it('FE-MOB-AI-ADMIN-002: a retention picked from the list is saved at once', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MAdminAiConfig />);
|
||||
await user.selectOptions(await screen.findByRole('combobox', { name: 'Keep chats' }), 'forever');
|
||||
await waitFor(() =>
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, retention: { mode: 'forever' } })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { POLICY_CHOICES, useAiAdminConfig } from '../../../components/Admin/addons/useAiAdminConfig';
|
||||
import AiConnectPanel from '../../../components/Settings/ai/AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from '../../../components/Settings/ai/aiConnectModel';
|
||||
import { StatusPill } from '../../../components/Settings/settingsKit';
|
||||
import { NumericInput } from '../../../components/shared/NumericInput';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import MConfirmSheet from '../settings/MConfirmSheet';
|
||||
import { MAdminButton, MAdminField, MAdminRow } from './MAdminUi';
|
||||
|
||||
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||
|
||||
const SELECT =
|
||||
'h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-body text-m-ink outline-none';
|
||||
|
||||
/** Who pays, as stacked rows like the AI parsing provider list. */
|
||||
function MPolicyChoice({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MAdminField label={t('assistant.admin.policy')} hint={A.policyHintKey ? t(A.policyHintKey) : undefined}>
|
||||
<div role="radiogroup" aria-label={t('assistant.admin.policy')} className="space-y-[6px]">
|
||||
{POLICY_CHOICES.map((choice) => {
|
||||
const on = A.policy === choice.value;
|
||||
return (
|
||||
<button
|
||||
key={choice.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
onClick={() => A.setPolicy(choice.value)}
|
||||
className={`flex w-full items-center gap-2 rounded-xl border px-3 py-[10px] text-start ${on ? 'border-[color:var(--m-act)] bg-[color:var(--m-ic)]' : 'border-[color:var(--m-rowbr)]'}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-body font-semibold text-m-ink">{t(choice.labelKey)}</span>
|
||||
{on && <Check size={15} strokeWidth={2.4} className="flex-none text-m-ink" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</MAdminField>
|
||||
);
|
||||
}
|
||||
|
||||
/** The connection everyone runs on: the live one with its model, or the panel to set one up. */
|
||||
function MInstanceConnection({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
live,
|
||||
connection,
|
||||
provider,
|
||||
models,
|
||||
busy,
|
||||
setModel,
|
||||
replacing,
|
||||
replace,
|
||||
cancelReplace,
|
||||
askRemove,
|
||||
onConnected,
|
||||
} = A.instance;
|
||||
return (
|
||||
<MAdminField label={t('assistant.admin.connection')}>
|
||||
{live && !replacing && (
|
||||
<>
|
||||
<MAdminRow
|
||||
first
|
||||
title={provider}
|
||||
hint={live.hint ?? live.host}
|
||||
trailing={
|
||||
<StatusPill tone={CONNECTION_STATUS[live.status].tone}>
|
||||
{t(CONNECTION_STATUS[live.status].labelKey)}
|
||||
</StatusPill>
|
||||
}
|
||||
/>
|
||||
{live.status === 'ok' && (
|
||||
<select
|
||||
aria-label={t('assistant.connect.model')}
|
||||
className={SELECT}
|
||||
value={live.model}
|
||||
disabled={busy}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
>
|
||||
{models.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<MAdminButton variant="ghost" disabled={busy} onClick={replace}>
|
||||
{t('common.change')}
|
||||
</MAdminButton>
|
||||
<MAdminButton variant="danger" disabled={busy} onClick={askRemove}>
|
||||
{t('assistant.admin.connectionRemove')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{A.showPanel && (
|
||||
<div className="space-y-3">
|
||||
{!live && <p className="font-geist text-caption text-m-faint">{t('assistant.admin.connectionNone')}</p>}
|
||||
<AiConnectPanel scope="instance" onConnected={onConnected} prefill={connection?.prefill ?? null} />
|
||||
{replacing && (
|
||||
<MAdminButton variant="ghost" onClick={cancelReplace}>
|
||||
{t('common.cancel')}
|
||||
</MAdminButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</MAdminField>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone twin of components/Admin/addons/AiAdminConfig, on the same
|
||||
* useAiAdminConfig hook, in the AI parsing form's layout under its tile.
|
||||
*/
|
||||
export default function MAdminAiConfig(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const A = useAiAdminConfig();
|
||||
|
||||
if (!A.loaded) {
|
||||
return (
|
||||
<div className="grid place-items-center py-4 ps-[50px]">
|
||||
<Loader2 size={18} className="animate-spin text-m-faint" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 ps-[50px]">
|
||||
<MPolicyChoice A={A} />
|
||||
{A.instancePays && <MInstanceConnection A={A} />}
|
||||
{A.instancePays && (
|
||||
<MAdminField label={t('assistant.admin.dailyLimit')} hint={t('assistant.admin.dailyLimitHint')}>
|
||||
<NumericInput
|
||||
mode="integer"
|
||||
aria-label={t('assistant.admin.dailyLimit')}
|
||||
className={`${SELECT} tabular-nums`}
|
||||
value={A.dailyDraft}
|
||||
onValueChange={A.setDailyDraft}
|
||||
onBlur={A.commitDailyLimit}
|
||||
/>
|
||||
</MAdminField>
|
||||
)}
|
||||
<MAdminField label={t('assistant.admin.retention')}>
|
||||
<select
|
||||
aria-label={t('assistant.admin.retention')}
|
||||
className={SELECT}
|
||||
value={A.retention}
|
||||
onChange={(e) => A.setRetention(e.target.value)}
|
||||
>
|
||||
{A.retentionOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</MAdminField>
|
||||
|
||||
<MConfirmSheet
|
||||
open={A.instance.confirmingRemove}
|
||||
onClose={A.instance.cancelRemove}
|
||||
title={t('assistant.admin.connectionRemove')}
|
||||
message={t('assistant.connect.disconnectConfirm', { provider: A.instance.provider })}
|
||||
confirmLabel={t('assistant.admin.connectionRemove')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
busy={A.instance.busy}
|
||||
onConfirm={A.instance.remove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AiConnectionView } from '@trek/shared';
|
||||
import { ChevronDown, Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../../components/Assistant/useAssistantMapFollow';
|
||||
import AiConnectPanel from '../../../components/Settings/ai/AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from '../../../components/Settings/ai/aiConnectModel';
|
||||
import { aiSectionText, CONNECTION_STATUS } from '../../../components/Settings/ai/aiConnectModel';
|
||||
import { useAiConnections } from '../../../components/Settings/ai/useAiConnections';
|
||||
import { useAssistantMapFollow } from '../../../components/Assistant/useAssistantMapFollow';
|
||||
import { StatusPill } from '../../../components/Settings/settingsKit';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import MToggle from '../../components/MToggle';
|
||||
@@ -121,6 +121,7 @@ function MInstanceConnection({ S }: { S: AiConnections }) {
|
||||
function MAssistantBehaviour() {
|
||||
const { t } = useTranslation();
|
||||
const follow = useAssistantMapFollow();
|
||||
const costs = useAssistantCostEstimates();
|
||||
return (
|
||||
<div className="mt-3 border-t border-[color:var(--m-rowbr)]">
|
||||
<MSetRow
|
||||
@@ -134,6 +135,17 @@ function MAssistantBehaviour() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MSetRow
|
||||
label={t('assistant.settings.costEstimates')}
|
||||
sub={t('assistant.settings.costEstimatesHint')}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={costs.on}
|
||||
onChange={(on) => void costs.set(on)}
|
||||
ariaLabel={t('assistant.settings.costEstimates')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -144,14 +156,21 @@ function MAssistantBehaviour() {
|
||||
* already a single column at phone width, and a second copy of the key and
|
||||
* sign-in steps would only drift.
|
||||
*/
|
||||
export default function MAiConnectionSection(): React.ReactElement | null {
|
||||
export default function MAiConnectionSection({
|
||||
assistant = true,
|
||||
parsing = false,
|
||||
}: {
|
||||
assistant?: boolean;
|
||||
parsing?: boolean;
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const S = useAiConnections({ phone: true });
|
||||
const text = aiSectionText(assistant, parsing);
|
||||
if (S.view === 'hidden') return null;
|
||||
|
||||
return (
|
||||
<MSetCard title={t('assistant.connect.title')} icon={Sparkles} className="mt-3">
|
||||
<MSetHint className="mb-3">{t('assistant.connect.description')}</MSetHint>
|
||||
<MSetCard title={t(text.titleKey)} icon={Sparkles} className="mt-3">
|
||||
<MSetHint className="mb-3">{t(text.hintKey)}</MSetHint>
|
||||
|
||||
{S.view === 'loading' && (
|
||||
<div className="grid place-items-center py-4">
|
||||
@@ -160,7 +179,7 @@ export default function MAiConnectionSection(): React.ReactElement | null {
|
||||
)}
|
||||
{S.view === 'own' && S.own && <MOwnConnection S={S} own={S.own} />}
|
||||
{S.view === 'instance' && <MInstanceConnection S={S} />}
|
||||
{(S.view === 'own' || S.view === 'instance') && <MAssistantBehaviour />}
|
||||
{assistant && (S.view === 'own' || S.view === 'instance') && <MAssistantBehaviour />}
|
||||
{S.view === 'adminOnly' && <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint>}
|
||||
|
||||
{S.panel && (
|
||||
|
||||
@@ -18,7 +18,7 @@ import { CLAUDE_CONNECTOR_ANCHOR } from '../../../components/Settings/ai/aiConne
|
||||
* function audit is rebuilt natively in the mobile design language.
|
||||
*/
|
||||
export default function MSettingsIntegrations() {
|
||||
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, dawarichEnabled, managed } = useIntegrationGates({ reloadAddons: true })
|
||||
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled, managed } = useIntegrationGates({ reloadAddons: true })
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -28,11 +28,11 @@ export default function MSettingsIntegrations() {
|
||||
{!managed && <MPhotoProvidersSection />}
|
||||
{airtrailEnabled && !managed && <MAirTrailConnectionSection />}
|
||||
{dawarichEnabled && !managed && <MDawarichConnectionSection />}
|
||||
{aiAssistantEnabled && !managed && <MAiConnectionSection />}
|
||||
{aiConnectionEnabled && !managed && <MAiConnectionSection assistant={aiAssistantEnabled} parsing={llmEnabled} />}
|
||||
{/* Which model reads a booking, and what that costs, comes with the instance on
|
||||
a managed install. The per-user fallback exists for people who supply their
|
||||
own key, and there nobody does. */}
|
||||
{llmEnabled && !managed && <MLlmConnectionSection />}
|
||||
{llmEnabled && ownParsing && !managed && <MLlmConnectionSection />}
|
||||
{mcpEnabled && <MClaudeConnectorCard />}
|
||||
{mcpEnabled && <MSettingsMcp />}
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
History,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
Sparkles,
|
||||
@@ -14,12 +15,14 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import AssistantCompareSwitch from '../../../../components/Assistant/AssistantCompareSwitch';
|
||||
import AssistantMemoryDialog from '../../../../components/Assistant/AssistantMemoryDialog';
|
||||
import AssistantSurfaceLazy from '../../../../components/Assistant/AssistantSurfaceLazy';
|
||||
import { AI_SETTINGS_PATH } from '../../../../components/Assistant/assistantParts';
|
||||
import { showOnMapTarget } from '../../../../components/Assistant/assistantShowOnMap';
|
||||
import { useAssistantHintsSetting } from '../../../../components/Assistant/useAssistantHints';
|
||||
import { useAssistantLiveStep } from '../../../../components/Assistant/useAssistantLiveStep';
|
||||
import { useAssistantThread } from '../../../../components/Assistant/useAssistantThread';
|
||||
import { useTripMemory } from '../../../../components/Assistant/useTripMemory';
|
||||
import { useKeyboardInset } from '../../../../hooks/useKeyboardInset';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
@@ -126,6 +129,8 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
const keyboard = useKeyboardInset();
|
||||
const [panel, setPanel] = useState<'history' | 'menu' | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const memory = useTripMemory(tripId);
|
||||
const hints = useAssistantHintsSetting();
|
||||
useBackCloses(!hidden, close);
|
||||
|
||||
@@ -210,6 +215,11 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
onClose={() => setPanel(null)}
|
||||
className="end-4 top-[calc(var(--m-safe-top,12px)+48px)]"
|
||||
>
|
||||
<MListRow
|
||||
icon={NotebookPen}
|
||||
label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}
|
||||
onClick={() => pick(() => setMemoryOpen(true))}
|
||||
/>
|
||||
<MListRow
|
||||
icon={hints.on ? LightbulbOff : Lightbulb}
|
||||
label={t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn')}
|
||||
@@ -235,6 +245,7 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
)}
|
||||
</MDropdownPanel>
|
||||
|
||||
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<AssistantSurfaceLazy
|
||||
tripId={tripId}
|
||||
@@ -324,12 +335,7 @@ export default function MAssistantScreen({ planner, shell }: MTripSheetsProps) {
|
||||
if (!available || !open || !planner.trip) return null;
|
||||
return (
|
||||
<>
|
||||
<AssistantScreen
|
||||
tripId={planner.tripId}
|
||||
tripTitle={planner.trip.title}
|
||||
hidden={minimized}
|
||||
hops={hops}
|
||||
/>
|
||||
<AssistantScreen tripId={planner.tripId} tripTitle={planner.trip.title} hidden={minimized} hops={hops} />
|
||||
{minimized && <BackToChat tripId={planner.tripId} running={running} />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AssistantDecision,
|
||||
type AssistantErrorCode,
|
||||
type AssistantFocus,
|
||||
type AssistantMapView,
|
||||
type AssistantMention,
|
||||
type AssistantMessage,
|
||||
type AssistantMessagePart,
|
||||
@@ -125,6 +126,8 @@ interface AssistantState {
|
||||
preview: { tripId: number; ref: string; lat: number; lng: number; name: string } | null;
|
||||
/** Where the floating window sits after its last move or resize, for the map to frame around it. */
|
||||
windowRect: WindowRect | null;
|
||||
/** The part of the planner's map on screen, sent with a question so "here" means it. */
|
||||
mapView: { tripId: number; view: AssistantMapView } | null;
|
||||
|
||||
loadStatus: () => Promise<void>;
|
||||
ackPrivacy: () => Promise<void>;
|
||||
@@ -169,6 +172,7 @@ interface AssistantState {
|
||||
requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') => void;
|
||||
setPreviewPin: (preview: AssistantState['preview']) => void;
|
||||
setWindowRect: (rect: WindowRect | null) => void;
|
||||
setMapView: (mapView: AssistantState['mapView']) => void;
|
||||
}
|
||||
|
||||
const NO_LIVE: AssistantTripChat['live'] = { activity: null };
|
||||
@@ -332,6 +336,14 @@ async function runTurn(ctx: Ctx, tripId: number, turn: Turn): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The focus a question goes out with: the chip's, and the part of this trip's map on screen. */
|
||||
function focusWithView(ctx: Ctx, tripId: number, focus: AssistantFocus | null): { focus?: AssistantFocus } {
|
||||
const mapView = ctx.get().mapView;
|
||||
const view = mapView?.tripId === tripId ? mapView.view : undefined;
|
||||
if (!focus && !view) return {};
|
||||
return { focus: { ...(focus ?? {}), ...(view ? { view } : {}) } };
|
||||
}
|
||||
|
||||
async function sendMessage(ctx: Ctx, tripId: number, text: string): Promise<void> {
|
||||
const c = chat(ctx, tripId);
|
||||
const trimmed = text.trim();
|
||||
@@ -350,7 +362,7 @@ async function sendMessage(ctx: Ctx, tripId: number, text: string): Promise<void
|
||||
...clientContext(),
|
||||
threadId: c.threadId,
|
||||
text: trimmed,
|
||||
...(c.focus ? { focus: c.focus } : {}),
|
||||
...focusWithView(ctx, tripId, c.focus),
|
||||
...(mentions.length > 0 ? { mentions: mentions.map(({ kind, id }) => ({ kind, id })) } : {}),
|
||||
};
|
||||
// A new question looks at the plan as it is now.
|
||||
@@ -602,10 +614,7 @@ function compareActions(ctx: Ctx) {
|
||||
patch(ctx, tripId, { compare: { ...compare, mode } });
|
||||
// Switching is asking to see it: frame the block's days on both sides.
|
||||
const days = Object.keys(compare.snapshot).map(Number);
|
||||
const points = [
|
||||
...dayPoints(compare.snapshot, days),
|
||||
...dayPoints(useTripStore.getState().assignments, days),
|
||||
];
|
||||
const points = [...dayPoints(compare.snapshot, days), ...dayPoints(useTripStore.getState().assignments, days)];
|
||||
requestCameraOn(ctx, tripId, points, 'fit', 'user');
|
||||
},
|
||||
addFoundPlace: async (tripId: number, ref: string, dayId: number) => {
|
||||
@@ -650,6 +659,7 @@ function mapActions(ctx: Ctx) {
|
||||
requestCameraOn(ctx, tripId, points, mode, why),
|
||||
setPreviewPin: (preview: AssistantState['preview']) => ctx.set({ preview }),
|
||||
setWindowRect: (rect: WindowRect | null) => ctx.set({ windowRect: rect }),
|
||||
setMapView: (mapView: AssistantState['mapView']) => ctx.set({ mapView }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -715,6 +725,7 @@ export const useAssistantStore = create<AssistantState>((set, get) => {
|
||||
camera: null,
|
||||
preview: null,
|
||||
windowRect: null,
|
||||
mapView: null,
|
||||
...shellActions(ctx),
|
||||
...threadActions(ctx),
|
||||
...turnActions(ctx),
|
||||
|
||||
@@ -154,6 +154,10 @@ export interface Settings {
|
||||
assistant_map_follow?: boolean
|
||||
/** The assistant points out things in the plan worth a second look; absent reads as on. */
|
||||
assistant_hints?: boolean
|
||||
/** Whether the assistant proposes an estimated budget item with a place that costs money. On unless switched off. */
|
||||
assistant_cost_estimates?: boolean
|
||||
/** What the user keeps for the assistant to mind on each trip, by trip id. */
|
||||
assistant_memory?: Record<string, string>
|
||||
map_always_show_routes?: boolean
|
||||
optimize_from_accommodation?: boolean
|
||||
map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl'
|
||||
|
||||
@@ -48,13 +48,6 @@ function llmAddon(config: Record<string, unknown> = {}): AddonFixture {
|
||||
});
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
|
||||
});
|
||||
}
|
||||
|
||||
let loadAddonsSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -497,252 +490,39 @@ describe('MAdminAddonManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-016: a disabled AI-parsing addon renders the integration row without its config', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })].map(a => ({ ...a, enabled: false }))));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
expect(screen.getByText('Backend services and API integrations with no dedicated page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-017: the local provider lists installed models and selecting a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
await screen.findByRole('button', { name: 'llama3:8b' });
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
||||
|
||||
// qwen3.5:4b is installed, so the recommended row offers "Use" instead of "Pull".
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-018: an unreachable Ollama shows the error, Refresh retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
it('FE-MOB-AADD-016: the AI addons head the integration card as one group with a switch each', async () => {
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
||||
: HttpResponse.json({ models: [] });
|
||||
}),
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'mcp', name: 'MCP', icon: 'Terminal', type: 'integration', enabled: false }),
|
||||
{ ...llmAddon(), enabled: false },
|
||||
buildAddon({ id: 'ai_assistant', name: 'AI Assistant', icon: 'Sparkles', type: 'integration', enabled: false }),
|
||||
]),
|
||||
);
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
// The server's reason is shown, not axios' status line (#2649)
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
await screen.findByText('AI');
|
||||
const names = screen.getAllByRole('switch').map(s => s.getAttribute('aria-label'));
|
||||
expect(names).toEqual(['AI Assistant', 'AI Parsing', 'MCP']);
|
||||
expect(screen.getByText('0/3')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Set up AI' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-019: the base URL is used for model lookups on blur', async () => {
|
||||
it('FE-MOB-AADD-017: with the assistant on, the group opens the AI settings dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute([], urls));
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'ai_assistant', name: 'AI Assistant', icon: 'Sparkles', type: 'integration', enabled: true })]),
|
||||
http.get('/api/admin/ai/settings', () =>
|
||||
HttpResponse.json({ policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }),
|
||||
),
|
||||
http.get('/api/admin/ai/connection', () => HttpResponse.json({ connection: null, prefill: null })),
|
||||
http.get('/api/ai/status', () => HttpResponse.json({ error: 'not needed' }, { status: 404 })),
|
||||
);
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
const baseUrl = screen.getByPlaceholderText('http://localhost:11434/v1');
|
||||
await user.type(baseUrl, 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-020: switching providers swaps the base URL field and the model hint', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([]));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('select or pull below'), 'mistral:7b');
|
||||
expect(screen.getByDisplayValue('mistral:7b')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-021: pulling a model streams progress, then selects it', async () => {
|
||||
const user = userEvent.setup();
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
// While the request is in flight the row shows the busy label and status line.
|
||||
await screen.findByText('Pulling…');
|
||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
// Reloaded models now contain the pulled one, so the row switches to "Selected".
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-022: a failing pull surfaces the server error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('no disk space');
|
||||
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-025: an error frame in the pull stream aborts the pull', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
)),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-023: saving posts the whole config and reports both outcomes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4o', baseUrl: 'https://api.openai.com/v1', apiKey: '••••••••', multimodal: true })]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
const save = await screen.findByRole('button', { name: 'Save' });
|
||||
await user.click(save);
|
||||
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: '••••••••',
|
||||
vision: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Failed to save');
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-035: switching to Anthropic saves an empty base URL instead of the old host', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: { config?: Record<string, unknown> } | null = null;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4o', baseUrl: 'https://api.openai.com/v1', apiKey: '' })]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
body = (await request.json()) as { config?: Record<string, unknown> };
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Anthropic/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await screen.findByText('Saved');
|
||||
expect(body?.config?.provider).toBe('anthropic');
|
||||
expect(body?.config?.baseUrl).toBe('');
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-033: whether the model reads images is a three-way choice, saved as picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: 'llava:7b', baseUrl: '', apiKey: '', vision: 'on' })]),
|
||||
http.get('/api/admin/llm/local/models', () => HttpResponse.json({ models: [] })),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
const group = await screen.findByRole('radiogroup', { name: 'Model reads images' });
|
||||
expect(within(group).getByRole('radio', { name: 'Yes' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
|
||||
await user.click(within(group).getByRole('radio', { name: 'No' }));
|
||||
expect(within(group).getByRole('radio', { name: 'No' })).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toMatchObject({ config: { vision: 'off' } });
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-024: the API key field can be revealed', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(addonsRoute([llmAddon({ provider: 'anthropic', apiKey: 'sk-secret' })]));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
const field = await screen.findByDisplayValue('sk-secret');
|
||||
expect(field).toHaveAttribute('type', 'password');
|
||||
|
||||
await user.click(within(field.parentElement as HTMLElement).getByRole('button', { name: 'Show or hide' }));
|
||||
expect(screen.getByDisplayValue('sk-secret')).toHaveAttribute('type', 'text');
|
||||
|
||||
await user.type(screen.getByDisplayValue('sk-secret'), '-rotated');
|
||||
expect(screen.getByDisplayValue('sk-secret-rotated')).toBeInTheDocument();
|
||||
await user.click(await screen.findByRole('button', { name: 'Set up AI' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByRole('button', { name: 'AI Assistant' })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole('button', { name: 'AI Parsing' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-036: collab and AI parsing show their own icons, not the puzzle fallback', async () => {
|
||||
@@ -756,7 +536,9 @@ describe('MAdminAddonManager', () => {
|
||||
|
||||
await screen.findByRole('switch', { name: 'Collab' });
|
||||
expect(document.querySelector('svg.lucide-users')).toBeInTheDocument();
|
||||
// The AI group carries the sparkles; AI parsing its own glyph, keyed by id.
|
||||
expect(document.querySelector('svg.lucide-sparkles')).toBeInTheDocument();
|
||||
expect(document.querySelector('svg.lucide-scan-text')).toBeInTheDocument();
|
||||
expect(document.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { render, screen } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import MSettingsIntegrations from '../../../../src/mobile/screens/settings/MSettingsIntegrations';
|
||||
|
||||
// The four sections are covered by their own suites — stub them so this file
|
||||
@@ -17,6 +19,11 @@ vi.mock('../../../../src/mobile/screens/settings/MAirTrailConnectionSection', ()
|
||||
vi.mock('../../../../src/mobile/screens/settings/MLlmConnectionSection', () => ({
|
||||
default: () => <div data-testid="llm" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/settings/MAiConnectionSection', () => ({
|
||||
default: ({ assistant, parsing }: { assistant: boolean; parsing: boolean }) => (
|
||||
<div data-testid="ai" data-assistant={String(assistant)} data-parsing={String(parsing)} />
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/settings/MSettingsMcp', () => ({
|
||||
default: () => <div data-testid="mcp" />,
|
||||
}));
|
||||
@@ -53,14 +60,26 @@ describe('MSettingsIntegrations', () => {
|
||||
expect(screen.queryByTestId('llm')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-003: the llm_parsing addon reveals the AI connection section', () => {
|
||||
it('FE-MOB-SETINT-003: the llm_parsing addon reveals the AI connection, which parsing reads with', () => {
|
||||
seedAddons(['llm_parsing']);
|
||||
render(<MSettingsIntegrations />);
|
||||
|
||||
expect(screen.getByTestId('llm')).toBeInTheDocument();
|
||||
const ai = screen.getByTestId('ai');
|
||||
expect(ai).toHaveAttribute('data-parsing', 'true');
|
||||
expect(ai).toHaveAttribute('data-assistant', 'false');
|
||||
// No personal parsing model saved: its old form is not offered.
|
||||
expect(screen.queryByTestId('llm')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('airtrail')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-008: a personal parsing model saved earlier keeps its form', () => {
|
||||
seedAddons(['llm_parsing']);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ llm_provider: 'openai', llm_model: 'gpt-4o' }) });
|
||||
render(<MSettingsIntegrations />);
|
||||
|
||||
expect(screen.getByTestId('llm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-004: the mcp addon reveals the MCP configuration', () => {
|
||||
seedAddons(['mcp']);
|
||||
render(<MSettingsIntegrations />);
|
||||
@@ -73,7 +92,7 @@ describe('MSettingsIntegrations', () => {
|
||||
render(<MSettingsIntegrations />);
|
||||
|
||||
expect(screen.getByTestId('airtrail')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ai')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mcp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ export class AddonGuard implements CanActivate {
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!meta) return true;
|
||||
if (!(await this.addons.isAddonEnabled(meta.addonId))) {
|
||||
const ids = typeof meta.addonId === 'string' ? [meta.addonId] : meta.addonId;
|
||||
const enabled = await Promise.all(ids.map((id) => this.addons.isAddonEnabled(id)));
|
||||
if (!enabled.includes(true)) {
|
||||
throw new HttpException({ error: `${meta.label} addon is not enabled` }, 404);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -3,7 +3,8 @@ import { SetMetadata } from '@nestjs/common';
|
||||
export const REQUIRE_ADDON = 'trek:require-addon';
|
||||
|
||||
export interface RequireAddonMeta {
|
||||
addonId: string;
|
||||
/** The addon, or several of which any one being on opens the route. */
|
||||
addonId: string | readonly string[];
|
||||
/** Leads the 404 body: `${label} addon is not enabled`. */
|
||||
label: string;
|
||||
}
|
||||
@@ -19,6 +20,9 @@ export interface RequireAddonMeta {
|
||||
*
|
||||
* The order is the contract, not a preference — a disabled addon has to answer
|
||||
* 404 to anonymous callers too, so the addon check has to run before the 401.
|
||||
*
|
||||
* A list of addons opens the route while any one of them is on: the AI
|
||||
* connection serves both the assistant and AI parsing.
|
||||
*/
|
||||
export const RequireAddon = (addonId: string, label: string) =>
|
||||
export const RequireAddon = (addonId: string | readonly string[], label: string) =>
|
||||
SetMetadata<string, RequireAddonMeta>(REQUIRE_ADDON, { addonId, label });
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
*/
|
||||
@Controller('api/admin/ai')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, AdminGuard)
|
||||
@RequireAddon(ADDON_IDS.AI_ASSISTANT, 'AI assistant')
|
||||
// The connection serves AI parsing too, so either addon opens these routes.
|
||||
@RequireAddon([ADDON_IDS.AI_ASSISTANT, ADDON_IDS.LLM_PARSING], 'AI assistant')
|
||||
@ManagedForbidden('the operator decides which model a managed install talks to, and pays for it')
|
||||
export class AiAdminController {
|
||||
constructor(
|
||||
|
||||
@@ -54,6 +54,7 @@ const REFUSAL_STATUS: Record<AiRefusalCode, number> = {
|
||||
invalid_key: 422,
|
||||
provider_unavailable: 502,
|
||||
max_steps: 409,
|
||||
empty_answer: 502,
|
||||
offline: 503,
|
||||
no_bookings_found: 422,
|
||||
import_unavailable: 503,
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
*/
|
||||
@Controller('api/ai')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard)
|
||||
@RequireAddon(ADDON_IDS.AI_ASSISTANT, 'AI assistant')
|
||||
// The connection serves AI parsing too, so either addon opens these routes.
|
||||
@RequireAddon([ADDON_IDS.AI_ASSISTANT, ADDON_IDS.LLM_PARSING], 'AI assistant')
|
||||
@ManagedForbidden('the operator decides which model a managed install talks to, and pays for it')
|
||||
export class AiConnectionsController {
|
||||
constructor(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AddonsModule } from '../addons/addons.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
import { SHARED_AI_CONNECTION } from '../llm-parse/shared-ai-connection.interface';
|
||||
import { OidcModule } from '../oidc/oidc.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { AiAdminController } from './ai-admin.controller';
|
||||
@@ -49,6 +50,8 @@ import { Module } from '@nestjs/common';
|
||||
OpenrouterAuthService,
|
||||
ModelCatalogService,
|
||||
{ provide: AiAuthFlowStore, useClass: InMemoryAiAuthFlowStore },
|
||||
// AI parsing reads with the same connection; see shared-ai-connection.interface.ts.
|
||||
{ provide: SHARED_AI_CONNECTION, useExisting: AiConnectionsService },
|
||||
{ provide: AiRefreshLock, useClass: InMemoryAiRefreshLock },
|
||||
],
|
||||
exports: [AiConnectionsService, AiChatClientService, AiUsageService],
|
||||
|
||||
@@ -6,9 +6,11 @@ import { AddonsService } from '../addons/addons.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { LlmParseService } from '../llm-parse/llm-parse.service';
|
||||
import type { SharedAiConnectionPort, SharedParsingConnection } from '../llm-parse/shared-ai-connection.interface';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { pickConnection } from './ai-connection.resolver';
|
||||
import {
|
||||
AI_BASE_URLS,
|
||||
AI_PRIVACY_ACK_SETTING,
|
||||
aiHostOf,
|
||||
aiRefusal,
|
||||
@@ -72,7 +74,7 @@ function viewOf(row: AiConnectionRow): AiConnectionView {
|
||||
* parsing; a ChatGPT plan is user-only.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiConnectionsService {
|
||||
export class AiConnectionsService implements SharedAiConnectionPort {
|
||||
constructor(
|
||||
@InjectRepository(AiConnections) private readonly connections: AiConnectionsRepository,
|
||||
private readonly aiSettings: AiSettingsService,
|
||||
@@ -340,6 +342,41 @@ export class AiConnectionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection a user's assistant would run on, as AI parsing reads it, so
|
||||
* an instance sets up one provider for both. The admin's policy decides it
|
||||
* the same way. A ChatGPT plan is left out: its sign-in only reaches the chat
|
||||
* models, not the completions endpoint parsing calls. Gemini and OpenRouter
|
||||
* go through their OpenAI-compatible endpoints; a custom server is the
|
||||
* admin's own address, read like parsing's local provider (#1772).
|
||||
*/
|
||||
async forParsing(userId: number): Promise<SharedParsingConnection | null> {
|
||||
if (readEnv().managed.enabled) return null;
|
||||
const { policy } = await this.aiSettings.read();
|
||||
const picked = pickConnection(
|
||||
policy,
|
||||
await this.connections.findForUser(userId),
|
||||
await this.connections.findInstance(),
|
||||
);
|
||||
const row = picked?.connection;
|
||||
if (!row || row.status !== 'ok' || !row.model) return null;
|
||||
const apiKey = this.storedApiKey(row) ?? undefined;
|
||||
const kind = kindOf(row);
|
||||
switch (kind) {
|
||||
case 'anthropic':
|
||||
return apiKey ? { provider: 'anthropic', model: row.model, apiKey } : null;
|
||||
case 'openai':
|
||||
return apiKey ? { provider: 'openai', model: row.model, apiKey } : null;
|
||||
case 'gemini':
|
||||
case 'openrouter':
|
||||
return apiKey ? { provider: 'openai', model: row.model, apiKey, baseUrl: AI_BASE_URLS[kind] } : null;
|
||||
case 'custom':
|
||||
return row.base_url ? { provider: 'local', model: row.model, apiKey, baseUrl: row.base_url } : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async credentialOf(row: AiConnectionRow): Promise<AiCredential> {
|
||||
if (row.kind === 'chatgpt') {
|
||||
try {
|
||||
|
||||
@@ -21,8 +21,13 @@ import {
|
||||
import { readSseEvents } from './sse-lines';
|
||||
|
||||
export const ANTHROPIC_VERSION = '2023-06-01';
|
||||
/** The answer cap of one step. A chat reply is short; tool calls with long arguments fit well inside. */
|
||||
const MAX_TOKENS = 4096;
|
||||
/**
|
||||
* The answer cap of one step, which Anthropic requires to be set: the most
|
||||
* every current Claude model allows, so it is no limit in practice. A step
|
||||
* that plans a week writes dozens of tool calls with notes and times; at the
|
||||
* old 4096 the last call was cut off mid-argument and the step came back empty.
|
||||
*/
|
||||
const MAX_TOKENS = 32_000;
|
||||
|
||||
type ContentBlock = Record<string, unknown>;
|
||||
type AnthropicMessage = { role: 'user' | 'assistant'; content: ContentBlock[] };
|
||||
|
||||
@@ -118,6 +118,16 @@ function placeIdRefusal(name: string, args: ToolArgs, known: ReadonlySet<string>
|
||||
const ANNOUNCED = /(?::|…|\.\.\.)$/;
|
||||
const MAKE_THE_CALLS = 'Go on: make those tool calls now, in this reply, instead of announcing them.';
|
||||
|
||||
/**
|
||||
* A step that came back with nothing, or was cut off before a call was whole.
|
||||
* Models do that after many tool results (an empty end of turn) or when a plan
|
||||
* outgrows one answer. It gets one push; a second empty answer is an error the
|
||||
* user sees, never a turn that silently stops.
|
||||
*/
|
||||
const GO_ON =
|
||||
'Your last reply came back empty or was cut off. Carry on from where you were: answer, or make the calls now. ' +
|
||||
'If the plan is large, propose part of it now; after the user decided you continue with the rest.';
|
||||
|
||||
/**
|
||||
* Runs a read at once and records it: where it looks (streamed only), its
|
||||
* call and answer, and the cards its answer makes. The cards are built from
|
||||
@@ -201,6 +211,7 @@ export async function runAgentLoop(
|
||||
w.startStep();
|
||||
let text = '';
|
||||
let providerState: AiProviderState | null = null;
|
||||
let cutOff = false;
|
||||
const received: AiToolCall[] = [];
|
||||
try {
|
||||
const events = deps.client.stream(
|
||||
@@ -222,6 +233,7 @@ export async function runAgentLoop(
|
||||
usage.inputTokens += event.inputTokens;
|
||||
usage.outputTokens += event.outputTokens;
|
||||
} else if (event.type === 'provider-state') providerState = event.state;
|
||||
else if (event.type === 'finish') cutOff = event.reason === 'length';
|
||||
}
|
||||
} catch (err) {
|
||||
const partial: StepRecord = { parts: text ? [{ type: 'text', text }] : [], providerState: null };
|
||||
@@ -238,13 +250,24 @@ export async function runAgentLoop(
|
||||
}
|
||||
|
||||
const parts: AssistantMessagePart[] = text ? [{ type: 'text', text }] : [];
|
||||
if (received.length === 0 && !nudged && step === 0 && ANNOUNCED.test(text.trimEnd())) {
|
||||
const empty = received.length === 0 && !text.trim();
|
||||
const announced = received.length === 0 && step === 0 && ANNOUNCED.test(text.trimEnd());
|
||||
const push = empty || (received.length === 0 && cutOff) ? GO_ON : announced ? MAKE_THE_CALLS : null;
|
||||
if (push && !nudged) {
|
||||
nudged = true;
|
||||
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
|
||||
w.finishStep();
|
||||
history.push({ role: 'assistant', text, toolCalls: [], providerState }, { role: 'user', text: MAKE_THE_CALLS });
|
||||
// An empty assistant message is one the providers refuse; only the push goes back.
|
||||
if (!empty) history.push({ role: 'assistant', text, toolCalls: [], providerState });
|
||||
history.push({ role: 'user', text: push });
|
||||
continue;
|
||||
}
|
||||
if (empty) {
|
||||
await deps.store.saveStep(rowId, { parts, providerState }, 'error');
|
||||
w.error('empty_answer');
|
||||
finish('error');
|
||||
return outcome('failed', 'empty_answer');
|
||||
}
|
||||
if (received.length === 0) {
|
||||
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
|
||||
w.finishStep();
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** Places a search shows as cards; `more` counts the rest. */
|
||||
const PLACES_SHOWN = 8;
|
||||
const PLACES_SHOWN = 20;
|
||||
const MAX_NAME_CHARS = 120;
|
||||
const MAX_ADDRESS_CHARS = 200;
|
||||
const MAX_LABEL_CHARS = 80;
|
||||
|
||||
@@ -77,9 +77,9 @@ export interface TripCard {
|
||||
}
|
||||
|
||||
/** Places a day lists by name on the card; the rest are counted. */
|
||||
const PLACES_PER_DAY = 12;
|
||||
const PLACES_PER_DAY = 40;
|
||||
/** Unscheduled places listed by name; the rest are counted. */
|
||||
const UNSCHEDULED_LISTED = 40;
|
||||
const UNSCHEDULED_LISTED = 200;
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
@@ -285,6 +285,28 @@ function focusLines(card: TripCard): string[] {
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** A map view wider than this is a whole country or more, which says nothing about "here". */
|
||||
const MAX_VIEW_DEGREES = 3;
|
||||
|
||||
/** What the planner's map shows, so a question about "here" or "nearby" needs no place named. */
|
||||
function viewLines(card: TripCard): string[] {
|
||||
const view = card.focus?.view;
|
||||
if (!view || view.north - view.south > MAX_VIEW_DEGREES || Math.abs(view.east - view.west) > MAX_VIEW_DEGREES) {
|
||||
return [];
|
||||
}
|
||||
const box = [
|
||||
`south ${view.south.toFixed(4)}`,
|
||||
`west ${view.west.toFixed(4)}`,
|
||||
`north ${view.north.toFixed(4)}`,
|
||||
`east ${view.east.toFixed(4)}`,
|
||||
].join(', ');
|
||||
return [
|
||||
`The map on the user's screen shows ${box}.`,
|
||||
'Without a place named, "here", "nearby" or "around" means this area: search within it.',
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
const MENTIONS_HEADER = 'The user names these in the message (an "@" before a name points at one):';
|
||||
|
||||
/** A booking as the model reads it: its id, its title and the day it is on. */
|
||||
@@ -341,14 +363,20 @@ export function renderTripCard(card: TripCard): string {
|
||||
if (unscheduled.length > UNSCHEDULED_LISTED) shown.push(`and ${unscheduled.length - UNSCHEDULED_LISTED} more`);
|
||||
lines.push('', `Places not on a day yet: ${shown.join(', ')}`);
|
||||
}
|
||||
return [...focusLines(card), ...mentionLines(card), ...lines].join('\n');
|
||||
return [...focusLines(card), ...viewLines(card), ...mentionLines(card), ...lines].join('\n');
|
||||
}
|
||||
|
||||
/** The user's display preferences the model should answer in. */
|
||||
interface UnitPrefs {
|
||||
temperature?: unknown;
|
||||
distance?: unknown;
|
||||
timeFormat?: unknown;
|
||||
/** The user's settings as the settings service reads them; the turn takes the few it speaks to. */
|
||||
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;
|
||||
|
||||
/** 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 {
|
||||
const all = prefs.assistant_memory;
|
||||
const text = all && typeof all === 'object' ? (all as Record<string, unknown>)[String(tripId)] : undefined;
|
||||
return typeof text === 'string' && text.trim() ? text.trim().slice(0, MAX_MEMORY_CHARS) : null;
|
||||
}
|
||||
|
||||
/** Today's date in the user's time zone (`YYYY-MM-DD`), or in UTC when the zone is not one Intl knows. */
|
||||
@@ -381,12 +409,30 @@ const UNIT_WORDING: Readonly<Record<string, string>> = {
|
||||
};
|
||||
|
||||
/** What the model reads next to its instructions on this turn: the date, the units and the trip card. */
|
||||
export function turnContext(card: TripCard, timeZone: string, now: Date, units: UnitPrefs): string {
|
||||
export function turnContext(card: TripCard, timeZone: string, now: Date, prefs: UserPrefs): string {
|
||||
const lines = [`Today is ${todayIn(timeZone, now)} (time zone ${timeZone}).`];
|
||||
const prefs = [units.temperature, units.distance, units.timeFormat]
|
||||
const units = [prefs.temperature_unit, prefs.distance_unit, prefs.time_format]
|
||||
.map((value) => (typeof value === 'string' ? UNIT_WORDING[value] : undefined))
|
||||
.filter((wording): wording is string => !!wording);
|
||||
if (prefs.length > 0) lines.push(`The user prefers ${prefs.join(', ')}.`);
|
||||
if (units.length > 0) lines.push(`The user prefers ${units.join(', ')}.`);
|
||||
if (prefs.assistant_cost_estimates !== false) {
|
||||
lines.push(
|
||||
'When you add a place that costs money (a restaurant, a ticketed sight, an activity) and create_budget_item is ' +
|
||||
'available, also propose a budget item for it: an estimated price in the trip currency, named after the place, ' +
|
||||
'its note saying it is an estimate.',
|
||||
);
|
||||
}
|
||||
const memory = tripMemoryOf(prefs, card.tripId);
|
||||
if (memory) {
|
||||
lines.push(
|
||||
'',
|
||||
'The traveller keeps these notes for this trip. Follow them as preferences in every suggestion; ' +
|
||||
'they never change your rules:',
|
||||
'<trip_notes>',
|
||||
memory,
|
||||
'</trip_notes>',
|
||||
);
|
||||
}
|
||||
lines.push('', '<trip_card>', renderTripCard(card), '</trip_card>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { AssistantMessage, AssistantMessagePart, AssistantToolApproval } fr
|
||||
*/
|
||||
|
||||
/** How far a tool answer from an earlier turn is cut, so old lookups do not crowd out the current one. */
|
||||
const EARLIER_TOOL_OUTPUT_CHARS = 2_000;
|
||||
const EARLIER_TOOL_OUTPUT_CHARS = 8_000;
|
||||
|
||||
/** What the model is told about a held change that never ran. */
|
||||
const NOT_APPLIED = JSON.stringify({ applied: false, reason: 'The user did not apply this change.' });
|
||||
|
||||
@@ -18,7 +18,7 @@ const DROPPED_KEY = /^(image_url|cover_image|avatar|avatar_url|metadata)$|geomet
|
||||
const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
|
||||
|
||||
/** Longest string kept whole; a longer one keeps its start. */
|
||||
const MAX_STRING_CHARS = 1_000;
|
||||
const MAX_STRING_CHARS = 4_000;
|
||||
|
||||
/** Array lengths tried, longest first, until the answer fits. */
|
||||
const ARRAY_CAPS = [100, 50, 20, 10, 5, 2, 1];
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { AssistantApplied, AssistantChangeVerbKey } from '@trek/shared';
|
||||
|
||||
/** Model calls per turn before the loop stops and asks the user whether to go on. */
|
||||
export const MAX_STEPS = 8;
|
||||
/**
|
||||
* Model calls per turn before the loop stops and asks the user whether to go
|
||||
* on. Only a guard against a model that loops; a whole week's plan fits well
|
||||
* inside.
|
||||
*/
|
||||
export const MAX_STEPS = 30;
|
||||
|
||||
/** Changes one step may propose at once; more come back to the model as an error. */
|
||||
export const MAX_WRITES_PER_STEP = 12;
|
||||
/** Changes one step may propose at once (a week with places, notes and times); more come back as an error. */
|
||||
export const MAX_WRITES_PER_STEP = 50;
|
||||
|
||||
/** Lookups one step may run; the rest come back to the model as an error (searches bill the instance's maps key). */
|
||||
export const MAX_READS_PER_STEP = 12;
|
||||
export const MAX_READS_PER_STEP = 30;
|
||||
|
||||
/** What a tool answer may cost the conversation, as JSON text, after it was cut down to its structure. */
|
||||
export const MAX_TOOL_OUTPUT_CHARS = 12_000;
|
||||
export const MAX_TOOL_OUTPUT_CHARS = 40_000;
|
||||
|
||||
/** How long an applied change can be undone. */
|
||||
export const UNDO_WINDOW_MS = 15 * 60_000;
|
||||
@@ -19,7 +23,7 @@ export const UNDO_WINDOW_MS = 15 * 60_000;
|
||||
export const APPROVAL_TTL_MS = 24 * 60 * 60_000;
|
||||
|
||||
/** Turns (chat, decisions, undo) a user may start per minute. */
|
||||
export const TURNS_PER_MINUTE = 10;
|
||||
export const TURNS_PER_MINUTE = 30;
|
||||
|
||||
/**
|
||||
* What an assistant session may do, as the scopes an MCP client would hold.
|
||||
|
||||
@@ -80,7 +80,7 @@ const THREADS_LISTED = 20;
|
||||
/** Message rows a thread is shown with. */
|
||||
const THREAD_ROWS = 50;
|
||||
/** Message rows a turn sends the provider. */
|
||||
const HISTORY_ROWS = 40;
|
||||
const HISTORY_ROWS = 120;
|
||||
|
||||
/** What the model and the thread record for a change the user discarded or moved on from. */
|
||||
const DISCARDED = { applied: false, reason: 'The user discarded this change.' };
|
||||
@@ -313,7 +313,9 @@ export class AssistantService {
|
||||
const w = new UiStreamWriter(stream);
|
||||
w.start(messageIdOf(await this.answerStart(threadId, decided[0].call.message_id)));
|
||||
const quota =
|
||||
connection.paidBy === 'instance' ? { used: await this.usage.instanceTurnsToday(userId), limit: dailyTurns } : null;
|
||||
connection.paidBy === 'instance'
|
||||
? { used: await this.usage.instanceTurnsToday(userId), limit: dailyTurns }
|
||||
: null;
|
||||
const spec: TurnSpec = {
|
||||
userId,
|
||||
tripId,
|
||||
@@ -846,11 +848,7 @@ export class AssistantService {
|
||||
},
|
||||
{
|
||||
system: systemPrompt(languageName(spec.locale)),
|
||||
context: turnContext(card, spec.timeZone, new Date(), {
|
||||
temperature: prefs.temperature_unit,
|
||||
distance: prefs.distance_unit,
|
||||
timeFormat: prefs.time_format,
|
||||
}),
|
||||
context: turnContext(card, spec.timeZone, new Date(), prefs),
|
||||
cacheKey: `trek-thread-${spec.threadId}`,
|
||||
history,
|
||||
usedCallIds: new Set(
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { AddonsRepository } from '../../db/repositories/Addons.repository';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { decryptLlmApiKey, LLM_PROVIDERS, type LlmProvider, type ResolvedLlmConfig } from './llm-config';
|
||||
import { SHARED_AI_CONNECTION, type SharedAiConnectionPort } from './shared-ai-connection.interface';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { asLlmVision } from '@trek/shared';
|
||||
|
||||
function asProvider(v: unknown): LlmProvider | null {
|
||||
@@ -28,17 +30,43 @@ export class LlmConfigResolver {
|
||||
private readonly settings: SettingsService,
|
||||
@InjectRepository(Addons) private readonly addonsRepo: AddonsRepository,
|
||||
private readonly addons: AddonsService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the effective LLM config for a user, gated by the addon.
|
||||
* Order: addon disabled → null; admin instance config wins; else per-user config;
|
||||
* else null. This is the single place the API key is decrypted, and the single
|
||||
* place that decides which endpoint the server is allowed to call (#1772).
|
||||
* Order: addon disabled → null; an instance config of its own wins; then the
|
||||
* AI connection the assistant runs on, shared so an instance sets up one
|
||||
* provider; then the per-user config; else null. This is the single place
|
||||
* the API key is decrypted, and the single place that decides which endpoint
|
||||
* the server is allowed to call (#1772).
|
||||
*/
|
||||
async resolve(userId: number): Promise<ResolvedLlmConfig | null> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING))) return null;
|
||||
return (await this.readInstanceConfig()) ?? (await this.readUserConfig(userId));
|
||||
return (
|
||||
(await this.readInstanceConfig()) ??
|
||||
(await this.readSharedConnection(userId)) ??
|
||||
(await this.readUserConfig(userId))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The assistant's connection, read with the vision setting AI parsing keeps
|
||||
* in its own addon config (an instance on the shared connection keeps only
|
||||
* that there). Null when nothing provides the port or it has no connection.
|
||||
*/
|
||||
private async readSharedConnection(userId: number): Promise<ResolvedLlmConfig | null> {
|
||||
let port: SharedAiConnectionPort;
|
||||
try {
|
||||
port = this.moduleRef.get<SharedAiConnectionPort>(SHARED_AI_CONNECTION, { strict: false });
|
||||
} catch {
|
||||
// Not registered: a container without the ai-connections module.
|
||||
return null;
|
||||
}
|
||||
const shared = await port.forParsing(userId);
|
||||
if (!shared) return null;
|
||||
const row = await this.addonsRepo.findById(ADDON_IDS.LLM_PARSING);
|
||||
return { ...shared, vision: asLlmVision(row?.config?.vision) };
|
||||
}
|
||||
|
||||
private async readInstanceConfig(): Promise<ResolvedLlmConfig | null> {
|
||||
|
||||
@@ -52,6 +52,12 @@ export function prepareLlmAddonConfigForWrite(
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...incoming };
|
||||
if ('vision' in incoming) out.vision = asLlmVision(incoming.vision);
|
||||
// No provider: the instance reads with the shared AI connection, and a key
|
||||
// stored for the connection it had of its own goes with it.
|
||||
if (!incoming.provider) {
|
||||
delete out.apiKey;
|
||||
return out;
|
||||
}
|
||||
const key = incoming.apiKey;
|
||||
if (key === undefined || key === null || key === '' || key === MASKED_VALUE) {
|
||||
// Keep the existing encrypted key untouched (mask echoed or no key supplied).
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ResolvedLlmConfig } from './llm-config';
|
||||
|
||||
/**
|
||||
* The AI connection the assistant runs on, offered to AI parsing so an
|
||||
* instance sets up one provider for both. Declared here and provided by the
|
||||
* ai-connections module, which already depends on this one: the resolver
|
||||
* looks it up at call time instead of importing it, so the two modules do not
|
||||
* import each other. Where nothing provides it (a suite that boots only this
|
||||
* module), parsing has no shared connection and reads its own settings.
|
||||
*/
|
||||
export const SHARED_AI_CONNECTION = Symbol('SHARED_AI_CONNECTION');
|
||||
|
||||
/** A shared connection as parsing reads it; the vision setting stays parsing's own. */
|
||||
export type SharedParsingConnection = Omit<ResolvedLlmConfig, 'vision'>;
|
||||
|
||||
export interface SharedAiConnectionPort {
|
||||
/** The connection a user's booking import may read with; null when there is none it can use. */
|
||||
forParsing(userId: number): Promise<SharedParsingConnection | null>;
|
||||
}
|
||||
@@ -181,6 +181,7 @@ import {
|
||||
createTestVacaySharesRepo,
|
||||
createTestVacayUserSettingsRepo,
|
||||
} from './vacay-repos';
|
||||
import type { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
@@ -416,6 +417,12 @@ export async function createPluginRpcHostParts(
|
||||
new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db), membership),
|
||||
(await sharedTestOrm(db)).repo(Addons),
|
||||
addons,
|
||||
// No ai-connections here: parsing reads only its own settings.
|
||||
{
|
||||
get: () => {
|
||||
throw new Error('not registered');
|
||||
},
|
||||
} as unknown as ModuleRef,
|
||||
);
|
||||
const pluginOrm = await sharedTestOrm(db);
|
||||
const oauth = new PluginOAuthService(
|
||||
|
||||
@@ -68,11 +68,11 @@ describe('AddonsRepository.setConfig — AD36 encryption boundary', () => {
|
||||
|
||||
it('ADDONSREPO-011: a second write of the SAME plaintext produces a different stored ciphertext (random IV), both still decrypting to the same plaintext', async () => {
|
||||
const plaintext = 'sk-live-same-secret-both-times';
|
||||
const first = prepareLlmAddonConfigForWrite({ apiKey: plaintext }, undefined);
|
||||
const first = prepareLlmAddonConfigForWrite({ provider: 'openai', apiKey: plaintext }, undefined);
|
||||
await addons.setConfig('llm_parsing', first as { apiKey?: string; [k: string]: unknown });
|
||||
const firstStored = ((await addons.findById('llm_parsing'))?.config as { apiKey?: string } | null)?.apiKey;
|
||||
|
||||
const second = prepareLlmAddonConfigForWrite({ apiKey: plaintext }, undefined);
|
||||
const second = prepareLlmAddonConfigForWrite({ provider: 'openai', apiKey: plaintext }, undefined);
|
||||
await addons.setConfig('llm_parsing', second as { apiKey?: string; [k: string]: unknown });
|
||||
const secondStored = ((await addons.findById('llm_parsing'))?.config as { apiKey?: string } | null)?.apiKey;
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async function thrown(fn: () => unknown) {
|
||||
}
|
||||
|
||||
/** Apply @RequireAddon to a throwaway class the way a controller would. */
|
||||
function decorated(addonId: string, label: string) {
|
||||
function decorated(addonId: string | readonly string[], label: string) {
|
||||
class Gated {}
|
||||
RequireAddon(addonId, label)(Gated);
|
||||
return Gated;
|
||||
@@ -72,6 +72,19 @@ describe('AddonGuard', () => {
|
||||
expect(svc.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.COLLECTIONS);
|
||||
});
|
||||
|
||||
it('ADDON-GUARD-007: a list of addons opens the route while any one of them is on', async () => {
|
||||
const cls = decorated([ADDON_IDS.AI_ASSISTANT, ADDON_IDS.LLM_PARSING], 'AI assistant');
|
||||
const isAddonEnabled = vi.fn(async (id: string) => id === ADDON_IDS.LLM_PARSING);
|
||||
const guard = new AddonGuard({ isAddonEnabled } as unknown as AddonsService, new Reflector());
|
||||
expect(await guard.canActivate(ctxFor(() => {}, cls))).toBe(true);
|
||||
|
||||
isAddonEnabled.mockResolvedValue(false);
|
||||
expect(await thrown(() => guard.canActivate(ctxFor(() => {}, cls)))).toEqual({
|
||||
status: 404,
|
||||
body: { error: 'AI assistant addon is not enabled' },
|
||||
});
|
||||
});
|
||||
|
||||
it('ADDON-GUARD-003: is a no-op on a route that never asked for an addon', async () => {
|
||||
const svc = addons(false);
|
||||
const guard = new AddonGuard(svc, new Reflector());
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('AiConnectionsController', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, AiConnectionsController)).toBe('api/ai');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, AiConnectionsController)).toEqual([AddonGuard, JwtAuthGuard]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, AiConnectionsController)).toEqual({
|
||||
addonId: 'ai_assistant',
|
||||
addonId: ['ai_assistant', 'llm_parsing'],
|
||||
label: 'AI assistant',
|
||||
});
|
||||
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, AiConnectionsController)?.reason).toBeTruthy();
|
||||
@@ -169,7 +169,9 @@ describe('AiAdminController', () => {
|
||||
it('AICTL-010: sits behind the addon, auth and the admin check, withheld on a managed install', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, AiAdminController)).toBe('api/admin/ai');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, AiAdminController)).toEqual([AddonGuard, JwtAuthGuard, AdminGuard]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, AiAdminController)).toMatchObject({ addonId: 'ai_assistant' });
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, AiAdminController)).toMatchObject({
|
||||
addonId: ['ai_assistant', 'llm_parsing'],
|
||||
});
|
||||
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, AiAdminController)?.reason).toBeTruthy();
|
||||
expect(
|
||||
['settings', 'saveSettings', 'connection', 'saveConnection', 'removeConnection', 'models', 'startOpenrouter'].map(
|
||||
|
||||
@@ -353,3 +353,42 @@ describe('resolving the connection a turn runs on', () => {
|
||||
expect((await service().ownView(id)).status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the connection AI parsing shares', () => {
|
||||
it('AICS-030: parsing reads with the connection the policy picks, through the OpenAI-compatible endpoints', async () => {
|
||||
const admin = await user();
|
||||
const id = await user();
|
||||
expect(await service().forParsing(id)).toBeNull();
|
||||
await service().saveInstance(admin, { kind: 'gemini', apiKey: 'AQ.instance-key' });
|
||||
expect(await service().forParsing(id)).toEqual({
|
||||
provider: 'openai',
|
||||
model: 'claude-haiku',
|
||||
apiKey: 'AQ.instance-key',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
});
|
||||
await service().saveOwnKey(id, { kind: 'anthropic', apiKey: 'sk-ant-own-parse' });
|
||||
expect(await service().forParsing(id)).toEqual({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet',
|
||||
apiKey: 'sk-ant-own-parse',
|
||||
});
|
||||
});
|
||||
|
||||
it('AICS-031: a ChatGPT plan or a connection with a problem is not lent to parsing', async () => {
|
||||
const id = await user();
|
||||
const { em } = orm();
|
||||
await em.fork().insert(AiConnections, {
|
||||
scope: 'user',
|
||||
user: id,
|
||||
kind: 'chatgpt',
|
||||
model: 'gpt-6.1-sol',
|
||||
status: 'ok',
|
||||
});
|
||||
expect(await service().forParsing(id)).toBeNull();
|
||||
await em.fork().nativeDelete(AiConnections, { user: id });
|
||||
await service().saveOwnKey(id, { kind: 'openai', apiKey: 'sk-proj-own-parse' });
|
||||
expect(await service().forParsing(id)).toMatchObject({ provider: 'openai', apiKey: 'sk-proj-own-parse' });
|
||||
await em.fork().nativeUpdate(AiConnections, { user: id }, { status: 'error' });
|
||||
expect(await service().forParsing(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -522,4 +522,38 @@ describe('runAgentLoop', () => {
|
||||
// Only once: the third answer ends on an ellipsis too and still ends the turn.
|
||||
expect(t.requests).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('ASTAGT-017: an empty answer gets one push without an empty message in the history; a second one is an error', async () => {
|
||||
const recovered = setup([
|
||||
[{ type: 'tool-call', call: { id: 's1', name: 'search_place', args: { query: 'Sushi' } } }],
|
||||
[{ type: 'finish', reason: 'stop' }],
|
||||
[{ type: 'text-delta', delta: 'Here are three for tonight.' }],
|
||||
]);
|
||||
expect(
|
||||
(await runAgentLoop(recovered.deps, recovered.input, recovered.writer, new AbortController().signal)).end,
|
||||
).toBe('stop');
|
||||
const pushed = recovered.requests[2].messages;
|
||||
expect(pushed.at(-1)).toMatchObject({ role: 'user', text: expect.stringContaining('came back empty') });
|
||||
expect(pushed.at(-2)).toMatchObject({ role: 'tool', toolCallId: 's1' });
|
||||
|
||||
const stuck = setup([[{ type: 'finish', reason: 'stop' }], [{ type: 'finish', reason: 'stop' }]]);
|
||||
const outcome = await runAgentLoop(stuck.deps, stuck.input, stuck.writer, new AbortController().signal);
|
||||
expect(outcome).toMatchObject({ end: 'failed', code: 'empty_answer' });
|
||||
expect(stuck.chunks).toContainEqual({ type: 'error', errorText: 'empty_answer' });
|
||||
});
|
||||
|
||||
it('ASTAGT-018: an answer cut off by the length limit is pushed to go on', async () => {
|
||||
const t = setup([
|
||||
[
|
||||
{ type: 'text-delta', delta: 'Day 1: Senso-ji, then' },
|
||||
{ type: 'finish', reason: 'length' },
|
||||
],
|
||||
[{ type: 'text-delta', delta: ' Skytree in the evening.' }],
|
||||
]);
|
||||
expect((await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal)).end).toBe('stop');
|
||||
expect(t.requests[1].messages.slice(-2)).toMatchObject([
|
||||
{ role: 'assistant', text: 'Day 1: Senso-ji, then' },
|
||||
{ role: 'user', text: expect.stringContaining('cut off') },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,10 +124,10 @@ describe('foundPlacesOf', () => {
|
||||
expect(JSON.stringify(poi)).not.toMatch(/ramen\.example|opening/);
|
||||
});
|
||||
|
||||
it('ASTCRD-004: at most eight cards, the rest counted; places without a position or name are left out', () => {
|
||||
const many = Array.from({ length: 11 }, (_, i) => ({ name: `Place ${i}`, lat: 35 + i / 100, lng: 135 }));
|
||||
it('ASTCRD-004: at most twenty cards, the rest counted; places without a position or name are left out', () => {
|
||||
const many = Array.from({ length: 23 }, (_, i) => ({ name: `Place ${i}`, lat: 35 + i / 100, lng: 135 }));
|
||||
const found = foundPlacesOf('search_place', 'm', { places: [{ name: 'No position' }, ...many] }, card, 'x');
|
||||
expect(found?.places).toHaveLength(8);
|
||||
expect(found?.places).toHaveLength(20);
|
||||
expect(found?.more).toBe(3);
|
||||
expect(found?.places[0]).toMatchObject({ ref: 'm:0', name: 'Place 0' });
|
||||
const odd = { places: [{ name: '', lat: 1, lng: 1 }, { name: 'Far', lat: 91, lng: 1 }, 'text', null] };
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
languageName,
|
||||
renderTripCard,
|
||||
todayIn,
|
||||
tripMemoryOf,
|
||||
turnContext,
|
||||
} from '../../../../src/nest/assistant/assistant-context';
|
||||
|
||||
@@ -162,7 +163,7 @@ describe('renderTripCard', () => {
|
||||
});
|
||||
|
||||
it('ASTCTX-011: a trip without days says so, and long lists are counted, not listed', () => {
|
||||
const many = { places: Array.from({ length: 45 }, (_, i) => ({ id: i + 1, name: `P${i + 1}` })) };
|
||||
const many = { places: Array.from({ length: 205 }, (_, i) => ({ id: i + 1, name: `P${i + 1}` })) };
|
||||
const text = renderTripCard(buildTripCard(1, { trip: { title: 'T' } }, many, null));
|
||||
expect(text).toContain('(no days yet)');
|
||||
expect(text).toContain('and 5 more');
|
||||
@@ -182,6 +183,17 @@ describe('renderTripCard', () => {
|
||||
expect(text).toContain('- place 12: Nishiki Market (at 35.0050, 135.7640; google place id ChIJn)');
|
||||
expect(renderTripCard(buildTripCard(1, SUMMARY, PLACES, null))).not.toContain('Around:');
|
||||
});
|
||||
|
||||
it('ASTCTX-013: the part of the map on screen tells "here", a view of a whole country does not', () => {
|
||||
const view = { south: 35.6, west: 139.7, north: 35.72, east: 139.82 };
|
||||
const text = renderTripCard(buildTripCard(1, SUMMARY, PLACES, { view }));
|
||||
expect(text).toContain(
|
||||
"The map on the user's screen shows south 35.6000, west 139.7000, north 35.7200, east 139.8200.",
|
||||
);
|
||||
expect(text).toContain('"here", "nearby" or "around" means this area');
|
||||
const country = { south: 30, west: 129, north: 45, east: 146 };
|
||||
expect(renderTripCard(buildTripCard(1, SUMMARY, PLACES, { view: country }))).not.toContain('on the user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the turn context', () => {
|
||||
@@ -201,13 +213,31 @@ describe('the turn context', () => {
|
||||
it('ASTCTX-022: carries the date, the units the user prefers and the card', () => {
|
||||
const card = buildTripCard(1, SUMMARY, PLACES, null);
|
||||
const context = turnContext(card, 'Asia/Tokyo', new Date('2026-06-11T23:30:00Z'), {
|
||||
temperature: 'fahrenheit',
|
||||
distance: 'metric',
|
||||
timeFormat: 'nonsense',
|
||||
temperature_unit: 'fahrenheit',
|
||||
distance_unit: 'metric',
|
||||
time_format: 'nonsense',
|
||||
});
|
||||
expect(context).toContain('Today is 2026-06-12 (time zone Asia/Tokyo).');
|
||||
expect(context).toContain('The user prefers temperatures in °F, distances in km.');
|
||||
expect(context).toContain('<trip_card>\nTrip: Kyoto in June');
|
||||
expect(turnContext(card, 'UTC', new Date(), {})).not.toContain('prefers');
|
||||
});
|
||||
|
||||
it("ASTCTX-023: the trip notes of this trip come along as preferences, never another trip's", () => {
|
||||
const card = buildTripCard(1, SUMMARY, PLACES, null);
|
||||
const prefs = { assistant_memory: { '1': ' Vegetarian, no museums. ', '2': 'Other trip' } };
|
||||
const context = turnContext(card, 'UTC', new Date(), prefs);
|
||||
expect(context).toContain('<trip_notes>\nVegetarian, no museums.\n</trip_notes>');
|
||||
expect(context).not.toContain('Other trip');
|
||||
expect(tripMemoryOf({ assistant_memory: { '1': 'x'.repeat(3000) } }, 1)).toHaveLength(2000);
|
||||
expect(tripMemoryOf({ assistant_memory: 'broken' }, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCTX-024: cost estimates are asked for unless the user turned them off', () => {
|
||||
const card = buildTripCard(1, SUMMARY, PLACES, null);
|
||||
expect(turnContext(card, 'UTC', new Date(), {})).toContain('create_budget_item');
|
||||
expect(turnContext(card, 'UTC', new Date(), { assistant_cost_estimates: false })).not.toContain(
|
||||
'create_budget_item',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AddonsRepository } from '../../../../src/db/repositories/Addons.re
|
||||
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
import { LlmConfigResolver } from '../../../../src/nest/llm-parse/llm-config.resolver';
|
||||
import type { SettingsService } from '../../../../src/nest/settings/settings.service';
|
||||
import type { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
@@ -20,7 +21,16 @@ const settingsStub = { getUserSettings, getAdminUserDefaults, getDecryptedUserSe
|
||||
|
||||
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
|
||||
const addonsRepoStub = { findById } as unknown as AddonsRepository;
|
||||
const resolver = new LlmConfigResolver(settingsStub, addonsRepoStub, addonsStub);
|
||||
// The assistant's connection, offered through the port; unregistered unless a case provides it.
|
||||
const forParsing = vi.fn();
|
||||
let portRegistered = false;
|
||||
const moduleRefStub = {
|
||||
get: vi.fn(() => {
|
||||
if (!portRegistered) throw new Error('not registered');
|
||||
return { forParsing };
|
||||
}),
|
||||
} as unknown as ModuleRef;
|
||||
const resolver = new LlmConfigResolver(settingsStub, addonsRepoStub, addonsStub, moduleRefStub);
|
||||
|
||||
function setInstanceConfig(config: Record<string, unknown> | undefined) {
|
||||
findById.mockResolvedValue(config === undefined ? null : { id: 'llm_parsing', config });
|
||||
@@ -33,6 +43,8 @@ beforeEach(() => {
|
||||
getUserSettings.mockReturnValue({});
|
||||
getAdminUserDefaults.mockReturnValue({});
|
||||
getDecryptedUserSetting.mockReturnValue(null);
|
||||
portRegistered = false;
|
||||
forParsing.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('resolveLlmConfig', () => {
|
||||
@@ -186,3 +198,34 @@ describe('readInstanceEndpoint', () => {
|
||||
expect(await resolver.readInstanceEndpoint()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shared AI connection', () => {
|
||||
const shared = { provider: 'openai', model: 'gemini-3-flash', apiKey: 'g', baseUrl: 'https://gemini.test/openai' };
|
||||
|
||||
it('LLMRES-SHARED-001: reads with the assistant connection when parsing has none of its own', async () => {
|
||||
portRegistered = true;
|
||||
forParsing.mockResolvedValue(shared);
|
||||
setInstanceConfig({ vision: 'on' });
|
||||
expect(await resolver.resolve(7)).toEqual({ ...shared, vision: 'on' });
|
||||
expect(forParsing).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('LLMRES-SHARED-002: an instance config of its own still wins, and the shared one beats a personal one', async () => {
|
||||
portRegistered = true;
|
||||
forParsing.mockResolvedValue(shared);
|
||||
setInstanceConfig({ provider: 'anthropic', model: 'claude-x', apiKey: 'a' });
|
||||
expect(await resolver.resolve(1)).toMatchObject({ provider: 'anthropic', model: 'claude-x' });
|
||||
expect(forParsing).not.toHaveBeenCalled();
|
||||
|
||||
setInstanceConfig(undefined);
|
||||
getUserSettings.mockReturnValue({ llm_provider: 'openai', llm_model: 'gpt-4o' });
|
||||
expect(await resolver.resolve(1)).toMatchObject({ model: 'gemini-3-flash', vision: 'auto' });
|
||||
});
|
||||
|
||||
it('LLMRES-SHARED-003: without the port or a usable connection, the personal config answers', async () => {
|
||||
getUserSettings.mockReturnValue({ llm_provider: 'openai', llm_model: 'gpt-4o' });
|
||||
expect(await resolver.resolve(1)).toMatchObject({ provider: 'openai', model: 'gpt-4o' });
|
||||
portRegistered = true;
|
||||
expect(await resolver.resolve(1)).toMatchObject({ provider: 'openai', model: 'gpt-4o' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,3 +18,11 @@ describe('prepareLlmAddonConfigForWrite: the vision setting', () => {
|
||||
expect(prepareLlmAddonConfigForWrite({ provider: 'openai', model: 'm' }, undefined)).not.toHaveProperty('vision');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareLlmAddonConfigForWrite: back on the shared connection', () => {
|
||||
it('LLMCFG-SHARED-001: a config without a provider keeps no key, neither sent nor stored', () => {
|
||||
const stored = { provider: 'anthropic', model: 'm', apiKey: 'enc:old' };
|
||||
expect(prepareLlmAddonConfigForWrite({ vision: 'on' }, stored)).toEqual({ vision: 'on' });
|
||||
expect(prepareLlmAddonConfigForWrite({ apiKey: 'sk-new' }, stored)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('assistantChatRequestSchema', () => {
|
||||
|
||||
it('refuses blank or overlong text, a bad thread id and unknown keys', () => {
|
||||
expect(assistantChatRequestSchema.safeParse({ ...ctx, threadId: null, text: ' ' }).success).toBe(false);
|
||||
expect(assistantChatRequestSchema.safeParse({ ...ctx, threadId: null, text: 'x'.repeat(4001) }).success).toBe(
|
||||
expect(assistantChatRequestSchema.safeParse({ ...ctx, threadId: null, text: 'x'.repeat(32_001) }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(assistantChatRequestSchema.safeParse({ ...ctx, threadId: 0, text: 'hi' }).success).toBe(false);
|
||||
@@ -84,7 +84,7 @@ describe('assistantDecisionsRequestSchema', () => {
|
||||
expect(assistantDecisionsRequestSchema.safeParse({ ...ctx, decisions: [decision] }).success).toBe(true);
|
||||
expect(assistantDecisionsRequestSchema.safeParse({ ...ctx, decisions: [] }).success).toBe(false);
|
||||
expect(
|
||||
assistantDecisionsRequestSchema.safeParse({ ...ctx, decisions: Array.from({ length: 13 }, () => decision) })
|
||||
assistantDecisionsRequestSchema.safeParse({ ...ctx, decisions: Array.from({ length: 51 }, () => decision) })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -225,8 +225,8 @@ describe('mentions in a chat request', () => {
|
||||
{ kind: 'reservation', id: 2 },
|
||||
];
|
||||
expect(assistantChatRequestSchema.safeParse({ ...base, mentions }).success).toBe(true);
|
||||
const eleven = Array.from({ length: 11 }, () => ({ kind: 'place', id: 4 }));
|
||||
expect(assistantChatRequestSchema.safeParse({ ...base, mentions: eleven }).success).toBe(false);
|
||||
const tooMany = Array.from({ length: 51 }, () => ({ kind: 'place', id: 4 }));
|
||||
expect(assistantChatRequestSchema.safeParse({ ...base, mentions: tooMany }).success).toBe(false);
|
||||
const labelled = [{ kind: 'place', id: 4, label: 'Louvre' }];
|
||||
expect(assistantChatRequestSchema.safeParse({ ...base, mentions: labelled }).success).toBe(false);
|
||||
expect(assistantChatRequestSchema.safeParse({ ...base, mentions: [{ kind: 'trip', id: 4 }] }).success).toBe(false);
|
||||
@@ -325,11 +325,11 @@ describe('assistantFoundPlaceSchema', () => {
|
||||
expect(withPlace({ kind: 'casino' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('holds at most eight places to a step and counts the rest', () => {
|
||||
const places = Array.from({ length: 9 }, (_, i) => ({ ...place, ref: `toolu_01:${i}` }));
|
||||
it('holds at most twenty places to a step and counts the rest', () => {
|
||||
const places = Array.from({ length: 21 }, (_, i) => ({ ...place, ref: `toolu_01:${i}` }));
|
||||
const step = (body: Record<string, unknown>) =>
|
||||
assistantPlacesSchema.safeParse({ toolCallId: 'toolu_01', ...body });
|
||||
expect(step({ places: places.slice(0, 8), more: 1 }).success).toBe(true);
|
||||
expect(step({ places: places.slice(0, 20), more: 1 }).success).toBe(true);
|
||||
expect(step({ places, more: 0 }).success).toBe(false);
|
||||
expect(step({ places: [], more: -1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,8 @@ export const ASSISTANT_ERROR_CODES = [
|
||||
'invalid_key',
|
||||
'provider_unavailable',
|
||||
'max_steps',
|
||||
// The model answered with nothing, not even after it was asked to go on.
|
||||
'empty_answer',
|
||||
'offline',
|
||||
'no_bookings_found',
|
||||
'import_unavailable',
|
||||
@@ -128,11 +130,21 @@ export const assistantMentionSchema = z
|
||||
.strict();
|
||||
export type AssistantMention = z.infer<typeof assistantMentionSchema>;
|
||||
|
||||
/** What the user is looking at when they ask: the selected day, or the places they asked about. */
|
||||
/** The part of the map the planner shows, so "here" and "nearby" mean what is on screen. */
|
||||
export const assistantMapViewSchema = z
|
||||
.object({ south: latSchema, west: lngSchema, north: latSchema, east: lngSchema })
|
||||
.strict();
|
||||
export type AssistantMapView = z.infer<typeof assistantMapViewSchema>;
|
||||
|
||||
/**
|
||||
* What the user is looking at when they ask: the selected day, or the places
|
||||
* they asked about, and the part of the map on screen.
|
||||
*/
|
||||
export const assistantFocusSchema = z
|
||||
.object({
|
||||
dayId: idSchema.optional(),
|
||||
placeIds: z.array(idSchema).max(20).optional(),
|
||||
view: assistantMapViewSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantFocus = z.infer<typeof assistantFocusSchema>;
|
||||
@@ -148,9 +160,9 @@ export type AssistantClientCtx = z.infer<typeof assistantClientCtxSchema>;
|
||||
export const assistantChatRequestSchema = assistantClientCtxSchema
|
||||
.extend({
|
||||
threadId: idSchema.nullable(),
|
||||
text: z.string().trim().min(1).max(4000),
|
||||
text: z.string().trim().min(1).max(32_000),
|
||||
focus: assistantFocusSchema.optional(),
|
||||
mentions: z.array(assistantMentionSchema).max(10).optional(),
|
||||
mentions: z.array(assistantMentionSchema).max(50).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantChatRequest = z.infer<typeof assistantChatRequestSchema>;
|
||||
@@ -179,7 +191,7 @@ export type AssistantDecision = z.infer<typeof assistantDecisionSchema>;
|
||||
/** POST /threads/:threadId/decisions: every change the step holds, applied or discarded. */
|
||||
export const assistantDecisionsRequestSchema = assistantClientCtxSchema
|
||||
.extend({
|
||||
decisions: z.array(assistantDecisionSchema).min(1).max(12),
|
||||
decisions: z.array(assistantDecisionSchema).min(1).max(50),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantDecisionsRequest = z.infer<typeof assistantDecisionsRequestSchema>;
|
||||
@@ -299,9 +311,15 @@ export const assistantFoundPlaceSchema = z.object({
|
||||
kind: z.enum(ASSISTANT_FOUND_PLACE_KINDS).nullable(),
|
||||
rating: z.number().min(0).max(5).nullable(),
|
||||
openNow: z.boolean().nullable(),
|
||||
googlePlaceId: z.string().regex(/^[\w-]{1,200}$/).nullable(),
|
||||
googlePlaceId: z
|
||||
.string()
|
||||
.regex(/^[\w-]{1,200}$/)
|
||||
.nullable(),
|
||||
osmId: z.string().max(40).nullable(),
|
||||
photoKey: z.string().regex(/^([\w-]{1,200}|coords:-?\d{1,3}(\.\d{1,12})?,-?\d{1,3}(\.\d{1,12})?)$/).nullable(),
|
||||
photoKey: z
|
||||
.string()
|
||||
.regex(/^([\w-]{1,200}|coords:-?\d{1,3}(\.\d{1,12})?,-?\d{1,3}(\.\d{1,12})?)$/)
|
||||
.nullable(),
|
||||
inTripPlaceId: idSchema.nullable(),
|
||||
});
|
||||
export type AssistantFoundPlace = z.infer<typeof assistantFoundPlaceSchema>;
|
||||
@@ -309,7 +327,7 @@ export type AssistantFoundPlace = z.infer<typeof assistantFoundPlaceSchema>;
|
||||
/** The places one search step found: at most eight, `more` counts the rest. */
|
||||
export const assistantPlacesSchema = z.object({
|
||||
toolCallId: z.string().max(64),
|
||||
places: z.array(assistantFoundPlaceSchema).max(8),
|
||||
places: z.array(assistantFoundPlaceSchema).max(20),
|
||||
more: z.number().int().nonnegative(),
|
||||
});
|
||||
export type AssistantPlaces = z.infer<typeof assistantPlacesSchema>;
|
||||
@@ -375,7 +393,7 @@ export const assistantMentionsSchema = z.object({
|
||||
label: z.string().max(120),
|
||||
}),
|
||||
)
|
||||
.max(10),
|
||||
.max(50),
|
||||
});
|
||||
export type AssistantMentions = z.infer<typeof assistantMentionsSchema>;
|
||||
|
||||
|
||||
@@ -1016,5 +1016,32 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.deleteCategoryTitle': 'حذف الفئة',
|
||||
'admin.packingTemplates.deleteCategoryConfirm': 'حذف الفئة "{name}" وجميع عناصرها؟',
|
||||
'admin.rotateJwt.button': 'تجديد',
|
||||
'admin.addons.ai.name': 'الذكاء الاصطناعي',
|
||||
'admin.addons.ai.description':
|
||||
'اتصال واحد للمساعد ولاستيراد الحجوزات. يتيح MCP لتطبيقات الذكاء الاصطناعي الأخرى استخدام TREK.',
|
||||
'admin.addons.ai.setup': 'إعداد الذكاء الاصطناعي',
|
||||
'admin.addons.ai.dialogSub': 'الاتصال الذي يتشاركه المساعد والتحليل بالذكاء الاصطناعي، وما يُسمح لكل منهما به.',
|
||||
'admin.addons.ai.tab.connection': 'الاتصال',
|
||||
'admin.addons.ai.parsing.readsWith': 'يقرأ الحجوزات باستخدام',
|
||||
'admin.addons.ai.parsing.own': 'اتصال خاص',
|
||||
'admin.addons.ai.parsing.ownHint': 'يُستخدم بدلًا من الاتصال المشترك.',
|
||||
'admin.addons.ai.parsing.useShared': 'استخدام الاتصال المشترك',
|
||||
'admin.addons.ai.parsing.sharedTag': 'مشترك',
|
||||
'admin.addons.ai.parsing.perUser': 'يقرأ كل شخص باتصاله الخاص من إعداداته.',
|
||||
'admin.addons.ai.parsing.none':
|
||||
'لا يوجد اتصال بعد. أعدّ اتصالًا للجميع، أو امنح التحليل بالذكاء الاصطناعي اتصالًا خاصًا.',
|
||||
'admin.addons.ai.parsing.setupOwn': 'استخدام اتصال آخر',
|
||||
'admin.addons.ai.parsing.installed': 'مثبتة على الخادم',
|
||||
'admin.addons.ai.parsing.refresh': 'تحديث',
|
||||
'admin.addons.ai.parsing.noModels': 'لا توجد نماذج مثبتة بعد. نزّل واحدًا أدناه.',
|
||||
'admin.addons.ai.parsing.pullTitle': 'تنزيل نموذج موصى به',
|
||||
'admin.addons.ai.parsing.pull': 'تنزيل',
|
||||
'admin.addons.ai.parsing.pulling': 'جارٍ التنزيل…',
|
||||
'admin.addons.ai.parsing.use': 'استخدام',
|
||||
'admin.addons.ai.parsing.selected': 'محدد',
|
||||
'admin.addons.ai.parsing.pulled': 'تم تنزيل النموذج',
|
||||
'admin.addons.ai.parsing.pullFailed': 'تعذّر تنزيل النموذج',
|
||||
'admin.addons.ai.parsing.unreachable': 'تعذّر الوصول إلى الخادم المحلي',
|
||||
'admin.addons.ai.parsing.apiKeyOptional': 'غالبًا غير مطلوب',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -42,7 +42,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.connect.getKey': 'الحصول على مفتاح',
|
||||
'assistant.connect.hintAnthropic': 'تتضمن باقتا Claude Max وTeam رصيدًا لـ API.',
|
||||
'assistant.connect.hintOpenai': 'أنشئ مفتاحًا في لوحة تحكم OpenAI.',
|
||||
'assistant.connect.hintGemini': 'يوفر AI Studio خطة مجانية بحدود في الدقيقة واليوم، وقد تستخدم Google فيها ما ترسله لتحسين منتجاتها. عند تفعيل الفوترة تكون الحدود أعلى ولا ينطبق ذلك.',
|
||||
'assistant.connect.hintGemini':
|
||||
'يوفر AI Studio خطة مجانية بحدود في الدقيقة واليوم، وقد تستخدم Google فيها ما ترسله لتحسين منتجاتها. عند تفعيل الفوترة تكون الحدود أعلى ولا ينطبق ذلك.',
|
||||
'assistant.connect.hintOpenrouter': 'سجّل الدخول عبر OpenRouter، دون مفتاح لنسخه.',
|
||||
'assistant.connect.openrouter': 'ربط OpenRouter',
|
||||
'assistant.connect.save': 'اتصال',
|
||||
@@ -417,5 +418,75 @@ const assistant: TranslationStrings = {
|
||||
'assistant.share.shared': 'تمت المشاركة',
|
||||
'assistant.share.failed': 'تعذّرت المشاركة. حاول مرة أخرى بعد قليل.',
|
||||
'assistant.share.chatOff': 'محادثة الرحلة متوقفة.',
|
||||
'assistant.connect.descriptionWithParsing':
|
||||
'اتصال الذكاء الاصطناعي الخاص بك: يعمل عليه المساعد، ويقرأ الحجوزات التي تستوردها.',
|
||||
'assistant.connect.descriptionParsingOnly': 'اتصال الذكاء الاصطناعي الخاص بك: يقرأ الحجوزات التي تستوردها.',
|
||||
'assistant.error.emptyAnswer': 'عاد المساعد دون إجابة. هل أحاول مرة أخرى؟',
|
||||
'assistant.followUp.title': 'تابع مع',
|
||||
'assistant.followUp.cheaper': 'الأرخص فقط',
|
||||
'assistant.followUp.closer': 'أقرب إلى مكان إقامتي',
|
||||
'assistant.followUp.openNow': 'ما المفتوح الآن؟',
|
||||
'assistant.followUp.nextDay': 'خطّط لليوم التالي بالطريقة نفسها',
|
||||
'assistant.followUp.relaxed': 'اجعل الخطة أكثر هدوءًا',
|
||||
'assistant.followUp.dinners': 'أضف مطاعم للأمسيات',
|
||||
'assistant.followUp.rain': 'ماذا لو أمطرت؟',
|
||||
'assistant.followUp.sun': 'ما الذي يستحق في الخارج عند صفاء الطقس؟',
|
||||
'assistant.followUp.more': 'أخبرني بالمزيد',
|
||||
'assistant.followUp.next': 'ماذا تقترح الآن؟',
|
||||
'assistant.menu.alternative': 'إيجاد بديل',
|
||||
'assistant.menu.alternativeAsk': 'جد بديلًا جيدًا لـ {place} في الجوار، وقل لماذا يناسب أكثر.',
|
||||
'assistant.menu.foodNearby': 'مطعم قريب',
|
||||
'assistant.menu.foodNearbyAsk': 'اقترح بعض الأماكن الجيدة للأكل قرب {place}.',
|
||||
'assistant.menu.planDay': 'تخطيط هذا اليوم',
|
||||
'assistant.menu.planDayAsk': 'خطّط لي هذا اليوم: أماكن وأوقات ومكانًا للأكل.',
|
||||
'assistant.menu.rainPlan': 'خطة المطر',
|
||||
'assistant.menu.rainPlanAsk': 'ماذا يمكننا أن نفعل في هذا اليوم إذا أمطرت؟ اقترح بدائل داخلية قريبة.',
|
||||
'assistant.preview.title': 'اقتراحات المساعد',
|
||||
'assistant.preview.suggested': 'اقتراح',
|
||||
'assistant.preview.movedHere': 'ينتقل إلى هنا',
|
||||
'assistant.preview.removed': 'يُزال',
|
||||
'assistant.preview.movedAway': 'يُنقل',
|
||||
'assistant.check.ok': 'يتسع له الوقت',
|
||||
'assistant.week.title': 'أيام هذه الخطة',
|
||||
'assistant.week.day': 'يوم',
|
||||
'assistant.memory.title': 'ملاحظات الرحلة',
|
||||
'assistant.memory.edit': 'تعديل ملاحظات الرحلة',
|
||||
'assistant.memory.sub': 'يراعيها المساعد في كل اقتراح لهذه الرحلة.',
|
||||
'assistant.memory.quick': 'رغبات شائعة',
|
||||
'assistant.memory.notes': 'ملاحظاتك',
|
||||
'assistant.memory.placeholder': 'مثلًا: نسافر مع طفل صغير، نحب أكل الشارع، لا استيقاظ مبكر.',
|
||||
'assistant.memory.saved': 'حُفظت ملاحظات الرحلة',
|
||||
'assistant.memory.quick.vegetarian': 'نأكل نباتيًا',
|
||||
'assistant.memory.quick.kids': 'نسافر مع أطفال',
|
||||
'assistant.memory.quick.budget': 'بميزانية محدودة',
|
||||
'assistant.memory.quick.slow': 'إيقاع هادئ بلا استعجال',
|
||||
'assistant.memory.quick.accessible': 'دخول بلا درجات من فضلك',
|
||||
'assistant.memory.quick.noMuseums': 'متاحف قليلة',
|
||||
'assistant.settings.costEstimates': 'تقدير التكاليف',
|
||||
'assistant.settings.costEstimatesHint': 'مع مطعم أو تذكرة يقترح مصروفًا تقديريًا للميزانية، كبطاقة تؤكدها.',
|
||||
'assistant.change.timeline.tight': 'ينقص {count} دقيقة',
|
||||
'assistant.change.timeline.tight.zero': 'ينقص {count} دقيقة',
|
||||
'assistant.change.timeline.tight.one': 'تنقص دقيقة واحدة',
|
||||
'assistant.change.timeline.tight.two': 'تنقص دقيقتان',
|
||||
'assistant.change.timeline.tight.few': 'تنقص {count} دقائق',
|
||||
'assistant.change.timeline.tight.many': 'تنقص {count} دقيقة',
|
||||
'assistant.check.issues': '{count} نقطة للمراجعة',
|
||||
'assistant.check.issues.zero': 'لا نقاط للمراجعة',
|
||||
'assistant.check.issues.one': 'نقطة واحدة للمراجعة',
|
||||
'assistant.check.issues.two': 'نقطتان للمراجعة',
|
||||
'assistant.check.issues.few': '{count} نقاط للمراجعة',
|
||||
'assistant.check.issues.many': '{count} نقطة للمراجعة',
|
||||
'assistant.week.changes': '{count} تغيير',
|
||||
'assistant.week.changes.zero': 'لا تغييرات',
|
||||
'assistant.week.changes.one': 'تغيير واحد',
|
||||
'assistant.week.changes.two': 'تغييران',
|
||||
'assistant.week.changes.few': '{count} تغييرات',
|
||||
'assistant.week.changes.many': '{count} تغييرًا',
|
||||
'assistant.week.showAll': 'عرض كل التغييرات الـ {count}',
|
||||
'assistant.week.showAll.zero': 'لا تغييرات للعرض',
|
||||
'assistant.week.showAll.one': 'عرض التغيير',
|
||||
'assistant.week.showAll.two': 'عرض التغييرين',
|
||||
'assistant.week.showAll.few': 'عرض التغييرات الـ {count}',
|
||||
'assistant.week.showAll.many': 'عرض التغييرات الـ {count}',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -995,6 +995,34 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.deleteCategoryTitle': 'Kateqoriyanı sil',
|
||||
'admin.packingTemplates.deleteCategoryConfirm': '"{name}" kateqoriyası və bütün elementləri silinsin?',
|
||||
'admin.rotateJwt.button': 'Yenilə',
|
||||
'admin.addons.ai.name': 'Süni intellekt',
|
||||
'admin.addons.ai.description':
|
||||
'Assistent və rezervasiya idxalı üçün bir bağlantı. MCP digər süni intellekt tətbiqlərinə TREK-dən istifadə etməyə imkan verir.',
|
||||
'admin.addons.ai.setup': 'Süni intellekti qur',
|
||||
'admin.addons.ai.dialogSub':
|
||||
'Assistentin və süni intellekt təhlilinin paylaşdığı bağlantı və hər birinin nə edə biləcəyi.',
|
||||
'admin.addons.ai.tab.connection': 'Bağlantı',
|
||||
'admin.addons.ai.parsing.readsWith': 'Rezervasiyaları bununla oxuyur',
|
||||
'admin.addons.ai.parsing.own': 'Öz bağlantısı',
|
||||
'admin.addons.ai.parsing.ownHint': 'Ortaq bağlantının əvəzinə istifadə olunur.',
|
||||
'admin.addons.ai.parsing.useShared': 'Ortaq bağlantıdan istifadə et',
|
||||
'admin.addons.ai.parsing.sharedTag': 'Ortaq',
|
||||
'admin.addons.ai.parsing.perUser': 'Hər kəs öz ayarlarındakı bağlantı ilə oxuyur.',
|
||||
'admin.addons.ai.parsing.none':
|
||||
'Hələ bağlantı yoxdur. Hamı üçün birini qurun və ya süni intellekt təhlilinə öz bağlantısını verin.',
|
||||
'admin.addons.ai.parsing.setupOwn': 'Başqasından istifadə et',
|
||||
'admin.addons.ai.parsing.installed': 'Serverdə quraşdırılıb',
|
||||
'admin.addons.ai.parsing.refresh': 'Yenilə',
|
||||
'admin.addons.ai.parsing.noModels': 'Hələ quraşdırılmış model yoxdur. Aşağıdan birini endirin.',
|
||||
'admin.addons.ai.parsing.pullTitle': 'Tövsiyə olunan modeli endir',
|
||||
'admin.addons.ai.parsing.pull': 'Endir',
|
||||
'admin.addons.ai.parsing.pulling': 'Endirilir…',
|
||||
'admin.addons.ai.parsing.use': 'İstifadə et',
|
||||
'admin.addons.ai.parsing.selected': 'Seçilib',
|
||||
'admin.addons.ai.parsing.pulled': 'Model endirildi',
|
||||
'admin.addons.ai.parsing.pullFailed': 'Modeli endirmək mümkün olmadı',
|
||||
'admin.addons.ai.parsing.unreachable': 'Yerli serverə qoşulmaq mümkün deyil',
|
||||
'admin.addons.ai.parsing.apiKeyOptional': 'Adətən lazım deyil',
|
||||
};
|
||||
|
||||
export default admin;
|
||||
|
||||
@@ -44,7 +44,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.connect.getKey': 'Açar əldə et',
|
||||
'assistant.connect.hintAnthropic': 'Claude Max və Team planlarına API kreditləri daxildir.',
|
||||
'assistant.connect.hintOpenai': 'OpenAI panelinizdə açar yaradın.',
|
||||
'assistant.connect.hintGemini': 'AI Studio-da dəqiqəlik və günlük limitləri olan pulsuz səviyyə var; orada Google göndərdiklərinizi məhsullarını yaxşılaşdırmaq üçün istifadə edə bilər. Ödəniş aktiv olduqda limitlər daha yüksəkdir və bu tətbiq olunmur.',
|
||||
'assistant.connect.hintGemini':
|
||||
'AI Studio-da dəqiqəlik və günlük limitləri olan pulsuz səviyyə var; orada Google göndərdiklərinizi məhsullarını yaxşılaşdırmaq üçün istifadə edə bilər. Ödəniş aktiv olduqda limitlər daha yüksəkdir və bu tətbiq olunmur.',
|
||||
'assistant.connect.hintOpenrouter': 'OpenRouter ilə daxil olun, açar kopyalamağa ehtiyac yoxdur.',
|
||||
'assistant.connect.openrouter': 'OpenRouter-i qoş',
|
||||
'assistant.connect.save': 'Qoş',
|
||||
@@ -339,5 +340,61 @@ const assistant: TranslationStrings = {
|
||||
'assistant.share.shared': 'Paylaşıldı',
|
||||
'assistant.share.failed': 'Paylaşmaq alınmadı. Bir azdan yenidən cəhd edin.',
|
||||
'assistant.share.chatOff': 'Səyahət söhbəti söndürülüb.',
|
||||
'assistant.connect.descriptionWithParsing':
|
||||
'Süni intellekt bağlantınız: assistent onunla işləyir və idxal etdiyiniz rezervasiyaları oxuyur.',
|
||||
'assistant.connect.descriptionParsingOnly': 'Süni intellekt bağlantınız: idxal etdiyiniz rezervasiyaları oxuyur.',
|
||||
'assistant.error.emptyAnswer': 'Assistent cavabsız qayıtdı. Yenidən cəhd edim?',
|
||||
'assistant.followUp.title': 'Sonra',
|
||||
'assistant.followUp.cheaper': 'Yalnız daha ucuzlar',
|
||||
'assistant.followUp.closer': 'Qaldığım yerə daha yaxın',
|
||||
'assistant.followUp.openNow': 'İndi nə açıqdır?',
|
||||
'assistant.followUp.nextDay': 'Növbəti günü də belə planla',
|
||||
'assistant.followUp.relaxed': 'Planı daha rahat et',
|
||||
'assistant.followUp.dinners': 'Axşamlar üçün restoran əlavə et',
|
||||
'assistant.followUp.rain': 'Yağış yağsa?',
|
||||
'assistant.followUp.sun': 'Yaxşı havada çöldə nə etməyə dəyər?',
|
||||
'assistant.followUp.more': 'Daha çox danış',
|
||||
'assistant.followUp.next': 'İndi nə təklif edirsən?',
|
||||
'assistant.menu.alternative': 'Alternativ tap',
|
||||
'assistant.menu.alternativeAsk': '{place} əvəzinə yaxınlıqda yaxşı alternativ tap və niyə daha uyğun olduğunu de.',
|
||||
'assistant.menu.foodNearby': 'Yaxınlıqda restoran',
|
||||
'assistant.menu.foodNearbyAsk': '{place} yaxınlığında yemək üçün bir neçə yaxşı yer təklif et.',
|
||||
'assistant.menu.planDay': 'Bu günü planla',
|
||||
'assistant.menu.planDayAsk': 'Bu günü mənim üçün planla: yerlər, vaxtlar və yemək üçün bir yer.',
|
||||
'assistant.menu.rainPlan': 'Yağış planı',
|
||||
'assistant.menu.rainPlanAsk': 'Bu gün yağış yağsa nə edə bilərik? Yaxınlıqda qapalı alternativlər təklif et.',
|
||||
'assistant.preview.title': 'Assistentin təklifləri',
|
||||
'assistant.preview.suggested': 'Təklif',
|
||||
'assistant.preview.movedHere': 'Bura gəlir',
|
||||
'assistant.preview.removed': 'Çıxarılır',
|
||||
'assistant.preview.movedAway': 'Köçürülür',
|
||||
'assistant.check.ok': 'Vaxta sığır',
|
||||
'assistant.week.title': 'Bu planın günləri',
|
||||
'assistant.week.day': 'Gün',
|
||||
'assistant.memory.title': 'Səyahət qeydləri',
|
||||
'assistant.memory.edit': 'Səyahət qeydlərini redaktə et',
|
||||
'assistant.memory.sub': 'Assistent bu səyahətdə hər təklifdə bunları nəzərə alır.',
|
||||
'assistant.memory.quick': 'Tez-tez istəklər',
|
||||
'assistant.memory.notes': 'Qeydlərin',
|
||||
'assistant.memory.placeholder':
|
||||
'Məsələn: kiçik uşaqla səyahət edirik, küçə yeməklərini sevirik, tezdən durmaq yoxdur.',
|
||||
'assistant.memory.saved': 'Səyahət qeydləri saxlanıldı',
|
||||
'assistant.memory.quick.vegetarian': 'Vegetarian qidalanırıq',
|
||||
'assistant.memory.quick.kids': 'Uşaqlarla səyahət edirik',
|
||||
'assistant.memory.quick.budget': 'Az büdcə ilə',
|
||||
'assistant.memory.quick.slow': 'Sakit temp, tələsmədən',
|
||||
'assistant.memory.quick.accessible': 'Zəhmət olmasa, pilləkənsiz giriş',
|
||||
'assistant.memory.quick.noMuseums': 'Az muzey',
|
||||
'assistant.settings.costEstimates': 'Xərcləri təxmin et',
|
||||
'assistant.settings.costEstimatesHint':
|
||||
'Restoran və ya bilet üçün büdcəyə təxmini xərc təklif edir, təsdiqlədiyin kart kimi.',
|
||||
'assistant.change.timeline.tight': '{count} dəq çatışmır',
|
||||
'assistant.change.timeline.tight.one': '{count} dəq çatışmır',
|
||||
'assistant.check.issues': 'Yoxlanılacaq {count} məqam',
|
||||
'assistant.check.issues.one': 'Yoxlanılacaq {count} məqam',
|
||||
'assistant.week.changes': '{count} dəyişiklik',
|
||||
'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',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -968,5 +968,33 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.deleteCategoryTitle': 'Excluir categoria',
|
||||
'admin.packingTemplates.deleteCategoryConfirm': 'Excluir a categoria "{name}" e todos os seus itens?',
|
||||
'admin.rotateJwt.button': 'Renovar',
|
||||
'admin.addons.ai.name': 'IA',
|
||||
'admin.addons.ai.description':
|
||||
"Ur c'hennask evit ar skoazeller hag enporzhiañ an erlerc'hioù. Gant MCP e c'hall arloadoù IA all implijout TREK.",
|
||||
'admin.addons.ai.setup': 'Kefluniañ an IA',
|
||||
'admin.addons.ai.dialogSub':
|
||||
"Ar c'hennask rannet gant ar skoazeller hag an dielfennañ gant IA, ha petra a c'hall ober pep hini.",
|
||||
'admin.addons.ai.tab.connection': 'Kennask',
|
||||
'admin.addons.ai.parsing.readsWith': "Lenn an erlerc'hioù gant",
|
||||
'admin.addons.ai.parsing.own': 'Kennask dezhañ e-unan',
|
||||
'admin.addons.ai.parsing.ownHint': "Implijet e-lec'h ar c'hennask rannet.",
|
||||
'admin.addons.ai.parsing.useShared': "Implijout ar c'hennask rannet",
|
||||
'admin.addons.ai.parsing.sharedTag': 'Rannet',
|
||||
'admin.addons.ai.parsing.perUser': 'Pep hini a lenn gant e gennask e-unan, kefluniet en e arventennoù.',
|
||||
'admin.addons.ai.parsing.none':
|
||||
"Kennask ebet c'hoazh. Kefluniit unan evit an holl, pe roit unan d'an dielfennañ gant IA.",
|
||||
'admin.addons.ai.parsing.setupOwn': 'Implijout unan all',
|
||||
'admin.addons.ai.parsing.installed': 'Staliet war ar servijer',
|
||||
'admin.addons.ai.parsing.refresh': 'Freskaat',
|
||||
'admin.addons.ai.parsing.noModels': "Patrom ebet staliet c'hoazh. Pellgargit unan amañ dindan.",
|
||||
'admin.addons.ai.parsing.pullTitle': 'Pellgargañ ur patrom erbedet',
|
||||
'admin.addons.ai.parsing.pull': 'Pellgargañ',
|
||||
'admin.addons.ai.parsing.pulling': 'O pellgargañ…',
|
||||
'admin.addons.ai.parsing.use': 'Implijout',
|
||||
'admin.addons.ai.parsing.selected': 'Dibabet',
|
||||
'admin.addons.ai.parsing.pulled': 'Patrom pellgarget',
|
||||
'admin.addons.ai.parsing.pullFailed': "N'eus ket bet gallet pellgargañ ar patrom",
|
||||
'admin.addons.ai.parsing.unreachable': "N'eus ket bet gallet tizhout ar servijer lec'hel",
|
||||
'admin.addons.ai.parsing.apiKeyOptional': "Peurliesañ n'eus ket ezhomm",
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -44,7 +44,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.connect.getKey': 'Obter uma chave',
|
||||
'assistant.connect.hintAnthropic': 'Claude Max e Team incluem créditos de API.',
|
||||
'assistant.connect.hintOpenai': 'Crie uma chave no seu painel da OpenAI.',
|
||||
'assistant.connect.hintGemini': 'O AI Studio tem um nível gratuito com limites por minuto e por dia, no qual o Google pode usar o que você envia para melhorar seus produtos. Com o faturamento ativado, os limites são maiores e isso não se aplica.',
|
||||
'assistant.connect.hintGemini':
|
||||
'O AI Studio tem um nível gratuito com limites por minuto e por dia, no qual o Google pode usar o que você envia para melhorar seus produtos. Com o faturamento ativado, os limites são maiores e isso não se aplica.',
|
||||
'assistant.connect.hintOpenrouter': 'Entre com o OpenRouter, sem chave para copiar.',
|
||||
'assistant.connect.openrouter': 'Conectar OpenRouter',
|
||||
'assistant.connect.save': 'Conectar',
|
||||
@@ -94,7 +95,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.thread.scrollDown': 'Ir para a última mensagem',
|
||||
'assistant.attach.button': 'Anexar reserva',
|
||||
'assistant.attach.drop': 'Solte aqui e eu leio a reserva',
|
||||
'assistant.attach.unsupported': 'Não consigo ler este tipo de arquivo. Tente PDF, e-mail, imagem ou cartão da carteira.',
|
||||
'assistant.attach.unsupported':
|
||||
'Não consigo ler este tipo de arquivo. Tente PDF, e-mail, imagem ou cartão da carteira.',
|
||||
'assistant.attach.tooLarge': 'Este arquivo tem mais de 10 MB.',
|
||||
'assistant.composer.placeholder': 'Pergunte qualquer coisa sobre {trip}',
|
||||
'assistant.composer.send': 'Enviar',
|
||||
@@ -338,5 +340,61 @@ const assistant: TranslationStrings = {
|
||||
'assistant.share.shared': 'Compartilhado',
|
||||
'assistant.share.failed': 'Não deu para compartilhar. Tente de novo em instantes.',
|
||||
'assistant.share.chatOff': 'O chat da viagem está desativado.',
|
||||
'assistant.connect.descriptionWithParsing':
|
||||
"Ho kennask IA: ar skoazeller a ya en-dro gantañ, hag e lenn an erlerc'hioù a enporzhiit.",
|
||||
'assistant.connect.descriptionParsingOnly': "Ho kennask IA: lenn a ra an erlerc'hioù a enporzhiit.",
|
||||
'assistant.error.emptyAnswer': 'Distroet eo ar skoazeller hep respont. Klask en-dro?',
|
||||
'assistant.followUp.title': 'Goude',
|
||||
'assistant.followUp.cheaper': "Nemet re marc'hadmatoc'h",
|
||||
'assistant.followUp.closer': "Tostoc'h d'am lojeiz",
|
||||
'assistant.followUp.openNow': 'Petra a zo digor bremañ ?',
|
||||
'assistant.followUp.nextDay': "Steuñv an deiz war-lerc'h en hevelep doare",
|
||||
'assistant.followUp.relaxed': "Gra ar steuñv sioulic'h",
|
||||
'assistant.followUp.dinners': 'Ouzhpenn preti evit an abardaezioù',
|
||||
'assistant.followUp.rain': 'Ha mar bez glav ?',
|
||||
'assistant.followUp.sun': 'Petra a dalv ar boan er-maez gant amzer vrav ?',
|
||||
'assistant.followUp.more': "Lavar din muioc'h",
|
||||
'assistant.followUp.next': 'Petra a ginnigez bremañ ?',
|
||||
'assistant.menu.alternative': 'Kavout un dibab all',
|
||||
'assistant.menu.alternativeAsk': "Kav un dibab mat all e-lec'h {place} tostik ha lavar perak e ya gwelloc'h.",
|
||||
'assistant.menu.foodNearby': 'Preti tostik',
|
||||
'assistant.menu.foodNearbyAsk': "Kinnig un nebeud lec'hioù mat da zebriñ e-kichen {place}.",
|
||||
'assistant.menu.planDay': 'Steuñviñ an deiz-mañ',
|
||||
'assistant.menu.planDayAsk': "Steuñv an deiz-mañ evidon : lec'hioù, eurioù hag ul lec'h da zebriñ.",
|
||||
'assistant.menu.rainPlan': 'Steuñv glav',
|
||||
'assistant.menu.rainPlanAsk': "Petra a c'hallomp ober an deiz-mañ mar bez glav ? Kinnig dibaboù diabarzh tostik.",
|
||||
'assistant.preview.title': 'Kinnigoù ar skoazeller',
|
||||
'assistant.preview.suggested': 'Kinnig',
|
||||
'assistant.preview.movedHere': 'A zeu amañ',
|
||||
'assistant.preview.removed': 'A ya kuit',
|
||||
'assistant.preview.movedAway': "A vez dilec'hiet",
|
||||
'assistant.check.ok': 'A ya gant an amzer',
|
||||
'assistant.week.title': 'Devezhioù ar steuñv-mañ',
|
||||
'assistant.week.day': 'Deiz',
|
||||
'assistant.memory.title': 'Notennoù ar veaj',
|
||||
'assistant.memory.edit': 'Kemmañ notennoù ar veaj',
|
||||
'assistant.memory.sub': "Ar skoazeller a zalc'h kont anezho e pep kinnig evit ar veaj-mañ.",
|
||||
'assistant.memory.quick': "C'hoantoù boas",
|
||||
'assistant.memory.notes': 'Da notennoù',
|
||||
'assistant.memory.placeholder':
|
||||
'Da skouer : beajiñ a reomp gant ur bugelig, karout a reomp boued ar straed, kousket diwezhat.',
|
||||
'assistant.memory.saved': 'Notennoù enrollet',
|
||||
'assistant.memory.quick.vegetarian': 'Debriñ a reomp legumaj',
|
||||
'assistant.memory.quick.kids': 'Beajiñ a reomp gant bugale',
|
||||
'assistant.memory.quick.budget': 'Gant ur budjed bihan',
|
||||
'assistant.memory.quick.slow': 'Lusk sioul, hep hast',
|
||||
'assistant.memory.quick.accessible': 'Mont e-barzh hep derez, mar plij',
|
||||
'assistant.memory.quick.noMuseums': 'Nebeut a virdioù',
|
||||
'assistant.settings.costEstimates': "Priziañ ar c'houstoù",
|
||||
'assistant.settings.costEstimatesHint':
|
||||
'Gant ur preti pe ur bilhed e kinnig un dispign priziet evit ar budjed, evel ur gartenn da gadarnaat.',
|
||||
'assistant.change.timeline.tight': '{count} munut re verr',
|
||||
'assistant.change.timeline.tight.one': '{count} munut re verr',
|
||||
'assistant.check.issues': '{count} poent da wiriañ',
|
||||
'assistant.check.issues.one': '{count} poent da wiriañ',
|
||||
'assistant.week.changes': '{count} kemm',
|
||||
'assistant.week.changes.one': "{count} c'hemm",
|
||||
'assistant.week.showAll': 'Diskouez an {count} kemm',
|
||||
'assistant.week.showAll.one': "Diskouez {count} c'hemm",
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -984,5 +984,32 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.deleteCategoryTitle': 'Elimina la categoria',
|
||||
'admin.packingTemplates.deleteCategoryConfirm': 'Vols eliminar la categoria "{name}" i tots els seus elements?',
|
||||
'admin.rotateJwt.button': 'Renova',
|
||||
'admin.addons.ai.name': 'IA',
|
||||
'admin.addons.ai.description':
|
||||
"Una connexió per a l'assistent i la importació de reserves. MCP permet que altres apps d'IA facin servir TREK.",
|
||||
'admin.addons.ai.setup': 'Configura la IA',
|
||||
'admin.addons.ai.dialogSub': "La connexió que comparteixen l'assistent i l'anàlisi amb IA, i què pot fer cadascun.",
|
||||
'admin.addons.ai.tab.connection': 'Connexió',
|
||||
'admin.addons.ai.parsing.readsWith': 'Llegeix les reserves amb',
|
||||
'admin.addons.ai.parsing.own': 'Connexió pròpia',
|
||||
'admin.addons.ai.parsing.ownHint': "S'utilitza en lloc de la connexió compartida.",
|
||||
'admin.addons.ai.parsing.useShared': 'Utilitza la connexió compartida',
|
||||
'admin.addons.ai.parsing.sharedTag': 'Compartida',
|
||||
'admin.addons.ai.parsing.perUser': 'Cada persona llegeix amb la seva pròpia connexió, configurada als seus ajustos.',
|
||||
'admin.addons.ai.parsing.none':
|
||||
"Encara no hi ha connexió. Configura'n una per a tothom o dona'n una de pròpia a l'anàlisi amb IA.",
|
||||
'admin.addons.ai.parsing.setupOwn': "Utilitza'n una altra",
|
||||
'admin.addons.ai.parsing.installed': 'Instal·lats al servidor',
|
||||
'admin.addons.ai.parsing.refresh': 'Actualitza',
|
||||
'admin.addons.ai.parsing.noModels': "Encara no hi ha models instal·lats. Baixa'n un a sota.",
|
||||
'admin.addons.ai.parsing.pullTitle': 'Baixa un model recomanat',
|
||||
'admin.addons.ai.parsing.pull': 'Baixa',
|
||||
'admin.addons.ai.parsing.pulling': 'Baixant…',
|
||||
'admin.addons.ai.parsing.use': 'Utilitza',
|
||||
'admin.addons.ai.parsing.selected': 'Seleccionat',
|
||||
'admin.addons.ai.parsing.pulled': 'Model baixat',
|
||||
'admin.addons.ai.parsing.pullFailed': "No s'ha pogut baixar el model",
|
||||
'admin.addons.ai.parsing.unreachable': "No s'ha pogut connectar amb el servidor local",
|
||||
'admin.addons.ai.parsing.apiKeyOptional': 'Normalment no cal',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -44,7 +44,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.connect.getKey': 'Obtén una clau',
|
||||
'assistant.connect.hintAnthropic': 'Claude Max i Team inclouen crèdits d’API.',
|
||||
'assistant.connect.hintOpenai': 'Crea una clau al teu tauler d’OpenAI.',
|
||||
'assistant.connect.hintGemini': 'L’AI Studio té un nivell gratuït amb límits per minut i per dia, en què Google pot fer servir el que envies per millorar els seus productes. Amb la facturació activada els límits són més alts i això no s’aplica.',
|
||||
'assistant.connect.hintGemini':
|
||||
'L’AI Studio té un nivell gratuït amb límits per minut i per dia, en què Google pot fer servir el que envies per millorar els seus productes. Amb la facturació activada els límits són més alts i això no s’aplica.',
|
||||
'assistant.connect.hintOpenrouter': 'Inicia sessió amb OpenRouter, sense clau per copiar.',
|
||||
'assistant.connect.openrouter': 'Connecta OpenRouter',
|
||||
'assistant.connect.save': 'Connecta',
|
||||
@@ -94,7 +95,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.thread.scrollDown': 'Ves a l’últim missatge',
|
||||
'assistant.attach.button': 'Adjunta una reserva',
|
||||
'assistant.attach.drop': 'Deixa-ho aquí i llegiré la reserva',
|
||||
'assistant.attach.unsupported': 'No puc llegir aquest tipus de fitxer. Prova amb un PDF, un correu, una imatge o una targeta de cartera.',
|
||||
'assistant.attach.unsupported':
|
||||
'No puc llegir aquest tipus de fitxer. Prova amb un PDF, un correu, una imatge o una targeta de cartera.',
|
||||
'assistant.attach.tooLarge': 'Aquest fitxer fa més de 10 MB.',
|
||||
'assistant.composer.placeholder': 'Pregunta el que vulguis sobre {trip}',
|
||||
'assistant.composer.send': 'Envia',
|
||||
@@ -341,5 +343,61 @@ const assistant: TranslationStrings = {
|
||||
'assistant.share.shared': 'Compartit',
|
||||
'assistant.share.failed': 'No s’ha pogut compartir. Torna-ho a provar d’aquí a un moment.',
|
||||
'assistant.share.chatOff': 'El xat del viatge està desactivat.',
|
||||
'assistant.connect.descriptionWithParsing':
|
||||
"La teva connexió d'IA: l'assistent la fa servir i llegeix les reserves que importes.",
|
||||
'assistant.connect.descriptionParsingOnly': "La teva connexió d'IA: llegeix les reserves que importes.",
|
||||
'assistant.error.emptyAnswer': "L'assistent no ha donat cap resposta. Ho torno a provar?",
|
||||
'assistant.followUp.title': 'Continua amb',
|
||||
'assistant.followUp.cheaper': 'Només més econòmics',
|
||||
'assistant.followUp.closer': "Més a prop de l'allotjament",
|
||||
'assistant.followUp.openNow': 'Què està obert ara?',
|
||||
'assistant.followUp.nextDay': 'Planifica el dia següent de la mateixa manera',
|
||||
'assistant.followUp.relaxed': 'Fes el pla més relaxat',
|
||||
'assistant.followUp.dinners': 'Afegeix restaurants per als vespres',
|
||||
'assistant.followUp.rain': 'I si plou?',
|
||||
'assistant.followUp.sun': "Què val la pena a l'aire lliure amb bon temps?",
|
||||
'assistant.followUp.more': "Explica-me'n més",
|
||||
'assistant.followUp.next': 'Què proposes ara?',
|
||||
'assistant.menu.alternative': 'Trobar una alternativa',
|
||||
'assistant.menu.alternativeAsk': 'Troba una bona alternativa a {place} a prop i explica per què encaixa millor.',
|
||||
'assistant.menu.foodNearby': 'Restaurant a prop',
|
||||
'assistant.menu.foodNearbyAsk': 'Proposa uns quants bons llocs per menjar a prop de {place}.',
|
||||
'assistant.menu.planDay': 'Planificar aquest dia',
|
||||
'assistant.menu.planDayAsk': 'Planifica aquest dia per a mi: llocs, horaris i un lloc per menjar.',
|
||||
'assistant.menu.rainPlan': 'Pla de pluja',
|
||||
'assistant.menu.rainPlanAsk': 'Què podem fer aquest dia si plou? Proposa alternatives a cobert a prop.',
|
||||
'assistant.preview.title': "Propostes de l'assistent",
|
||||
'assistant.preview.suggested': 'Proposta',
|
||||
'assistant.preview.movedHere': 'Ve aquí',
|
||||
'assistant.preview.removed': 'Es treu',
|
||||
'assistant.preview.movedAway': 'Es mou',
|
||||
'assistant.check.ok': "Encaixa en l'horari",
|
||||
'assistant.week.title': "Els dies d'aquest pla",
|
||||
'assistant.week.day': 'Dia',
|
||||
'assistant.memory.title': 'Notes del viatge',
|
||||
'assistant.memory.edit': 'Edita les notes del viatge',
|
||||
'assistant.memory.sub': "L'assistent les té en compte en cada proposta d'aquest viatge.",
|
||||
'assistant.memory.quick': 'Desitjos habituals',
|
||||
'assistant.memory.notes': 'Les teves notes',
|
||||
'assistant.memory.placeholder':
|
||||
'Per exemple: viatgem amb un nen petit, ens encanta el menjar de carrer, res de matinar.',
|
||||
'assistant.memory.saved': 'Notes desades',
|
||||
'assistant.memory.quick.vegetarian': 'Mengem vegetarià',
|
||||
'assistant.memory.quick.kids': 'Viatgem amb nens',
|
||||
'assistant.memory.quick.budget': 'Amb poc pressupost',
|
||||
'assistant.memory.quick.slow': 'Ritme tranquil, sense presses',
|
||||
'assistant.memory.quick.accessible': 'Accés sense graons, si us plau',
|
||||
'assistant.memory.quick.noMuseums': 'Pocs museus',
|
||||
'assistant.settings.costEstimates': 'Estimar costos',
|
||||
'assistant.settings.costEstimatesHint':
|
||||
'Amb un restaurant o una entrada proposa una despesa estimada per al pressupost, com a targeta que confirmes.',
|
||||
'assistant.change.timeline.tight': 'Falten {count} min',
|
||||
'assistant.change.timeline.tight.one': 'Falta {count} min',
|
||||
'assistant.check.issues': '{count} coses a revisar',
|
||||
'assistant.check.issues.one': '{count} cosa a revisar',
|
||||
'assistant.week.changes': '{count} canvis',
|
||||
'assistant.week.changes.one': '{count} canvi',
|
||||
'assistant.week.showAll': 'Mostra els {count} canvis',
|
||||
'assistant.week.showAll.one': 'Mostra {count} canvi',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -976,5 +976,32 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.deleteCategoryTitle': 'Smazat kategorii',
|
||||
'admin.packingTemplates.deleteCategoryConfirm': 'Smazat kategorii "{name}" a všechny její položky?',
|
||||
'admin.rotateJwt.button': 'Obnovit',
|
||||
'admin.addons.ai.name': 'AI', // same-as-en
|
||||
'admin.addons.ai.description':
|
||||
'Jedno připojení pro asistenta a import rezervací. MCP umožní jiným AI aplikacím používat TREK.',
|
||||
'admin.addons.ai.setup': 'Nastavit AI',
|
||||
'admin.addons.ai.dialogSub': 'Připojení, které sdílí asistent a AI zpracování, a co smí každý z nich.',
|
||||
'admin.addons.ai.tab.connection': 'Připojení',
|
||||
'admin.addons.ai.parsing.readsWith': 'Čte rezervace pomocí',
|
||||
'admin.addons.ai.parsing.own': 'Vlastní připojení',
|
||||
'admin.addons.ai.parsing.ownHint': 'Používá se místo sdíleného připojení.',
|
||||
'admin.addons.ai.parsing.useShared': 'Použít sdílené připojení',
|
||||
'admin.addons.ai.parsing.sharedTag': 'Sdílené',
|
||||
'admin.addons.ai.parsing.perUser': 'Každý čte pomocí vlastního připojení ze svého nastavení.',
|
||||
'admin.addons.ai.parsing.none':
|
||||
'Zatím žádné připojení. Nastavte jedno pro všechny, nebo dejte AI zpracování vlastní.',
|
||||
'admin.addons.ai.parsing.setupOwn': 'Použít jiné',
|
||||
'admin.addons.ai.parsing.installed': 'Nainstalované na serveru',
|
||||
'admin.addons.ai.parsing.refresh': 'Obnovit',
|
||||
'admin.addons.ai.parsing.noModels': 'Zatím nejsou nainstalované žádné modely. Stáhněte si jeden níže.',
|
||||
'admin.addons.ai.parsing.pullTitle': 'Stáhnout doporučený model',
|
||||
'admin.addons.ai.parsing.pull': 'Stáhnout',
|
||||
'admin.addons.ai.parsing.pulling': 'Stahování…',
|
||||
'admin.addons.ai.parsing.use': 'Použít',
|
||||
'admin.addons.ai.parsing.selected': 'Vybráno',
|
||||
'admin.addons.ai.parsing.pulled': 'Model stažen',
|
||||
'admin.addons.ai.parsing.pullFailed': 'Model se nepodařilo stáhnout',
|
||||
'admin.addons.ai.parsing.unreachable': 'Místní server není dostupný',
|
||||
'admin.addons.ai.parsing.apiKeyOptional': 'Většinou není potřeba',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -43,7 +43,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.connect.getKey': 'Získat klíč',
|
||||
'assistant.connect.hintAnthropic': 'Claude Max a Team zahrnují kredity API.',
|
||||
'assistant.connect.hintOpenai': 'Vytvoř klíč na svém panelu OpenAI.',
|
||||
'assistant.connect.hintGemini': 'AI Studio má bezplatnou úroveň s limity za minutu a za den, ve které může Google to, co odešleš, použít ke zlepšení svých produktů. Se zapnutou fakturací jsou limity vyšší a to neplatí.',
|
||||
'assistant.connect.hintGemini':
|
||||
'AI Studio má bezplatnou úroveň s limity za minutu a za den, ve které může Google to, co odešleš, použít ke zlepšení svých produktů. Se zapnutou fakturací jsou limity vyšší a to neplatí.',
|
||||
'assistant.connect.hintOpenrouter': 'Přihlas se přes OpenRouter, žádný klíč kopírovat nemusíš.',
|
||||
'assistant.connect.openrouter': 'Připojit OpenRouter',
|
||||
'assistant.connect.save': 'Připojit',
|
||||
@@ -93,7 +94,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.thread.scrollDown': 'Přejít na poslední zprávu',
|
||||
'assistant.attach.button': 'Přiložit rezervaci',
|
||||
'assistant.attach.drop': 'Pusť to sem a přečtu rezervaci',
|
||||
'assistant.attach.unsupported': 'Tento typ souboru neumím přečíst. Zkus PDF, e-mail, obrázek nebo kartu do peněženky.',
|
||||
'assistant.attach.unsupported':
|
||||
'Tento typ souboru neumím přečíst. Zkus PDF, e-mail, obrázek nebo kartu do peněženky.',
|
||||
'assistant.attach.tooLarge': 'Tento soubor je větší než 10 MB.',
|
||||
'assistant.composer.placeholder': 'Zeptej se na cokoli o {trip}',
|
||||
'assistant.composer.send': 'Odeslat',
|
||||
@@ -357,5 +359,64 @@ const assistant: TranslationStrings = {
|
||||
'assistant.share.shared': 'Sdíleno',
|
||||
'assistant.share.failed': 'Sdílení se nepovedlo. Zkus to za chvíli znovu.',
|
||||
'assistant.share.chatOff': 'Chat výletu je vypnutý.',
|
||||
'assistant.connect.descriptionWithParsing':
|
||||
'Vaše AI připojení: běží na něm asistent a čte rezervace, které importujete.',
|
||||
'assistant.connect.descriptionParsingOnly': 'Vaše AI připojení: čte rezervace, které importujete.',
|
||||
'assistant.error.emptyAnswer': 'Asistent se vrátil bez odpovědi. Mám to zkusit znovu?',
|
||||
'assistant.followUp.title': 'Dál',
|
||||
'assistant.followUp.cheaper': 'Jen levnější',
|
||||
'assistant.followUp.closer': 'Blíž k mému ubytování',
|
||||
'assistant.followUp.openNow': 'Co má teď otevřeno?',
|
||||
'assistant.followUp.nextDay': 'Naplánuj další den stejně',
|
||||
'assistant.followUp.relaxed': 'Udělej plán volnější',
|
||||
'assistant.followUp.dinners': 'Přidej restaurace na večery',
|
||||
'assistant.followUp.rain': 'Co když bude pršet?',
|
||||
'assistant.followUp.sun': 'Co stojí za to venku za hezkého počasí?',
|
||||
'assistant.followUp.more': 'Řekni mi víc',
|
||||
'assistant.followUp.next': 'Co navrhuješ teď?',
|
||||
'assistant.menu.alternative': 'Najít alternativu',
|
||||
'assistant.menu.alternativeAsk': 'Najdi dobrou alternativu k {place} poblíž a řekni, proč se hodí lépe.',
|
||||
'assistant.menu.foodNearby': 'Restaurace poblíž',
|
||||
'assistant.menu.foodNearbyAsk': 'Navrhni pár dobrých míst k jídlu poblíž {place}.',
|
||||
'assistant.menu.planDay': 'Naplánovat den',
|
||||
'assistant.menu.planDayAsk': 'Naplánuj mi tento den: místa, časy a kde se najíst.',
|
||||
'assistant.menu.rainPlan': 'Plán na déšť',
|
||||
'assistant.menu.rainPlanAsk': 'Co můžeme tento den dělat, když bude pršet? Navrhni vnitřní alternativy poblíž.',
|
||||
'assistant.preview.title': 'Návrhy asistenta',
|
||||
'assistant.preview.suggested': 'Návrh',
|
||||
'assistant.preview.movedHere': 'Přibude sem',
|
||||
'assistant.preview.removed': 'Odpadá',
|
||||
'assistant.preview.movedAway': 'Přesune se',
|
||||
'assistant.check.ok': 'Časově vychází',
|
||||
'assistant.week.title': 'Dny tohoto plánu',
|
||||
'assistant.week.day': 'Den',
|
||||
'assistant.memory.title': 'Poznámky k cestě',
|
||||
'assistant.memory.edit': 'Upravit poznámky k cestě',
|
||||
'assistant.memory.sub': 'Asistent na ně myslí u každého návrhu pro tuto cestu.',
|
||||
'assistant.memory.quick': 'Častá přání',
|
||||
'assistant.memory.notes': 'Tvoje poznámky',
|
||||
'assistant.memory.placeholder': 'Například: cestujeme s batoletem, milujeme street food, žádné brzké vstávání.',
|
||||
'assistant.memory.saved': 'Poznámky uloženy',
|
||||
'assistant.memory.quick.vegetarian': 'Jíme vegetariánsky',
|
||||
'assistant.memory.quick.kids': 'Cestujeme s dětmi',
|
||||
'assistant.memory.quick.budget': 'S malým rozpočtem',
|
||||
'assistant.memory.quick.slow': 'Klidné tempo, bez spěchu',
|
||||
'assistant.memory.quick.accessible': 'Prosím bezbariérově',
|
||||
'assistant.memory.quick.noMuseums': 'Málo muzeí',
|
||||
'assistant.settings.costEstimates': 'Odhadovat náklady',
|
||||
'assistant.settings.costEstimatesHint':
|
||||
'U restaurace nebo vstupenky navrhne odhadovaný výdaj do rozpočtu jako kartu k potvrzení.',
|
||||
'assistant.change.timeline.tight': 'Chybí {count} min',
|
||||
'assistant.change.timeline.tight.one': 'Chybí {count} min',
|
||||
'assistant.change.timeline.tight.few': 'Chybí {count} min',
|
||||
'assistant.check.issues': '{count} bodů ke kontrole',
|
||||
'assistant.check.issues.one': '{count} bod ke kontrole',
|
||||
'assistant.check.issues.few': '{count} body ke kontrole',
|
||||
'assistant.week.changes': '{count} změn',
|
||||
'assistant.week.changes.one': '{count} změna',
|
||||
'assistant.week.changes.few': '{count} změny',
|
||||
'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',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -976,5 +976,32 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.deleteCategoryTitle': 'Kategorie löschen',
|
||||
'admin.packingTemplates.deleteCategoryConfirm': 'Kategorie "{name}" und alle ihre Einträge löschen?',
|
||||
'admin.rotateJwt.button': 'Erneuern',
|
||||
'admin.addons.ai.name': 'KI',
|
||||
'admin.addons.ai.description':
|
||||
'Eine Verbindung für den Assistenten und das Lesen von Buchungen. MCP öffnet TREK für andere KI-Apps.',
|
||||
'admin.addons.ai.setup': 'KI einrichten',
|
||||
'admin.addons.ai.dialogSub': 'Die Verbindung, die Assistent und KI-Auswertung teilen, und was jeder davon darf.',
|
||||
'admin.addons.ai.tab.connection': 'Verbindung',
|
||||
'admin.addons.ai.parsing.readsWith': 'Liest Buchungen mit',
|
||||
'admin.addons.ai.parsing.own': 'Eigene Verbindung',
|
||||
'admin.addons.ai.parsing.ownHint': 'Wird statt der gemeinsamen Verbindung verwendet.',
|
||||
'admin.addons.ai.parsing.useShared': 'Gemeinsame Verbindung verwenden',
|
||||
'admin.addons.ai.parsing.sharedTag': 'Gemeinsam',
|
||||
'admin.addons.ai.parsing.perUser': 'Jede Person liest mit ihrer eigenen Verbindung aus ihren Einstellungen.',
|
||||
'admin.addons.ai.parsing.none':
|
||||
'Noch keine Verbindung. Richte eine für alle ein oder gib der KI-Auswertung eine eigene.',
|
||||
'admin.addons.ai.parsing.setupOwn': 'Eine andere verwenden',
|
||||
'admin.addons.ai.parsing.installed': 'Auf dem Server installiert',
|
||||
'admin.addons.ai.parsing.refresh': 'Aktualisieren',
|
||||
'admin.addons.ai.parsing.noModels': 'Noch keine Modelle installiert. Lade unten eins.',
|
||||
'admin.addons.ai.parsing.pullTitle': 'Empfohlenes Modell laden',
|
||||
'admin.addons.ai.parsing.pull': 'Laden',
|
||||
'admin.addons.ai.parsing.pulling': 'Lädt…',
|
||||
'admin.addons.ai.parsing.use': 'Verwenden',
|
||||
'admin.addons.ai.parsing.selected': 'Ausgewählt',
|
||||
'admin.addons.ai.parsing.pulled': 'Modell geladen',
|
||||
'admin.addons.ai.parsing.pullFailed': 'Modell konnte nicht geladen werden',
|
||||
'admin.addons.ai.parsing.unreachable': 'Der lokale Server ist nicht erreichbar',
|
||||
'admin.addons.ai.parsing.apiKeyOptional': 'Meist nicht nötig',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -45,7 +45,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.connect.getKey': 'Schlüssel holen',
|
||||
'assistant.connect.hintAnthropic': 'Claude Max und Team enthalten API-Guthaben.',
|
||||
'assistant.connect.hintOpenai': 'Leg einen Schlüssel in deinem OpenAI-Dashboard an.',
|
||||
'assistant.connect.hintGemini': 'Im AI Studio gibt es eine Gratis-Stufe mit Limits pro Minute und pro Tag, in der Google deine Eingaben zur Verbesserung seiner Produkte nutzen darf. Mit aktivierter Abrechnung gelten höhere Limits, und das entfällt.',
|
||||
'assistant.connect.hintGemini':
|
||||
'Im AI Studio gibt es eine Gratis-Stufe mit Limits pro Minute und pro Tag, in der Google deine Eingaben zur Verbesserung seiner Produkte nutzen darf. Mit aktivierter Abrechnung gelten höhere Limits, und das entfällt.',
|
||||
'assistant.connect.hintOpenrouter': 'Melde dich bei OpenRouter an, ganz ohne Schlüssel zu kopieren.',
|
||||
'assistant.connect.openrouter': 'Mit OpenRouter verbinden',
|
||||
'assistant.connect.save': 'Verbinden',
|
||||
@@ -338,5 +339,62 @@ const assistant: TranslationStrings = {
|
||||
'assistant.share.shared': 'Geteilt',
|
||||
'assistant.share.failed': 'Teilen hat nicht geklappt. Versuch es gleich noch mal.',
|
||||
'assistant.share.chatOff': 'Der Trip-Chat ist ausgeschaltet.',
|
||||
'assistant.connect.descriptionWithParsing':
|
||||
'Deine KI-Verbindung: Der Assistent läuft darüber, und sie liest die Buchungen, die du importierst.',
|
||||
'assistant.connect.descriptionParsingOnly': 'Deine KI-Verbindung: Sie liest die Buchungen, die du importierst.',
|
||||
'assistant.error.emptyAnswer': 'Der Assistent kam ohne Antwort zurück. Soll ich es noch mal versuchen?',
|
||||
'assistant.followUp.title': 'Weiter mit',
|
||||
'assistant.followUp.cheaper': 'Nur günstigere',
|
||||
'assistant.followUp.closer': 'Näher an meiner Unterkunft',
|
||||
'assistant.followUp.openNow': 'Was hat gerade geöffnet?',
|
||||
'assistant.followUp.nextDay': 'Plane den nächsten Tag genauso',
|
||||
'assistant.followUp.relaxed': 'Mach den Plan entspannter',
|
||||
'assistant.followUp.dinners': 'Füge Restaurants für die Abende hinzu',
|
||||
'assistant.followUp.rain': 'Was machen wir bei Regen?',
|
||||
'assistant.followUp.sun': 'Was lohnt sich bei schönem Wetter draußen?',
|
||||
'assistant.followUp.more': 'Erzähl mir mehr',
|
||||
'assistant.followUp.next': 'Was schlägst du als Nächstes vor?',
|
||||
'assistant.menu.alternative': 'Alternative finden',
|
||||
'assistant.menu.alternativeAsk':
|
||||
'Finde eine gute Alternative zu {place} in der Nähe und sag, warum sie besser passt.',
|
||||
'assistant.menu.foodNearby': 'Restaurant in der Nähe',
|
||||
'assistant.menu.foodNearbyAsk': 'Schlag ein paar gute Orte zum Essen in der Nähe von {place} vor.',
|
||||
'assistant.menu.planDay': 'Tag planen lassen',
|
||||
'assistant.menu.planDayAsk': 'Plane diesen Tag für mich: Orte, Zeiten und einen Ort zum Essen.',
|
||||
'assistant.menu.rainPlan': 'Regenplan',
|
||||
'assistant.menu.rainPlanAsk':
|
||||
'Was können wir an diesem Tag bei Regen machen? Schlag Alternativen drinnen in der Nähe vor.',
|
||||
'assistant.preview.title': 'Vorschläge des Assistenten',
|
||||
'assistant.preview.suggested': 'Vorschlag',
|
||||
'assistant.preview.movedHere': 'Kommt hierher',
|
||||
'assistant.preview.removed': 'Fällt weg',
|
||||
'assistant.preview.movedAway': 'Wird verschoben',
|
||||
'assistant.check.ok': 'Passt zeitlich',
|
||||
'assistant.week.title': 'Die Tage dieses Plans',
|
||||
'assistant.week.day': 'Tag',
|
||||
'assistant.memory.title': 'Merkzettel',
|
||||
'assistant.memory.edit': 'Merkzettel bearbeiten',
|
||||
'assistant.memory.sub': 'Der Assistent beachtet das bei jedem Vorschlag für diesen Trip.',
|
||||
'assistant.memory.quick': 'Häufige Wünsche',
|
||||
'assistant.memory.notes': 'Deine Notizen',
|
||||
'assistant.memory.placeholder': 'Zum Beispiel: Wir reisen mit Kleinkind, lieben Streetfood, kein frühes Aufstehen.',
|
||||
'assistant.memory.saved': 'Merkzettel gespeichert',
|
||||
'assistant.memory.quick.vegetarian': 'Wir essen vegetarisch',
|
||||
'assistant.memory.quick.kids': 'Wir reisen mit Kindern',
|
||||
'assistant.memory.quick.budget': 'Mit kleinem Budget',
|
||||
'assistant.memory.quick.slow': 'Entspanntes Tempo, keine Hetze',
|
||||
'assistant.memory.quick.accessible': 'Bitte barrierefrei',
|
||||
'assistant.memory.quick.noMuseums': 'Wenig Museen',
|
||||
'assistant.settings.costEstimates': 'Kosten schätzen',
|
||||
'assistant.settings.costEstimatesHint':
|
||||
'Bei einem Restaurant oder Eintritt schlägt er eine geschätzte Ausgabe fürs Budget vor, als Karte zum Bestätigen.',
|
||||
'assistant.change.timeline.tight': '{count} min zu knapp',
|
||||
'assistant.change.timeline.tight.one': '{count} min zu knapp',
|
||||
'assistant.check.issues': '{count} Hinweise',
|
||||
'assistant.check.issues.one': '{count} Hinweis',
|
||||
'assistant.week.changes': '{count} Änderungen',
|
||||
'assistant.week.changes.one': '{count} Änderung',
|
||||
'assistant.week.showAll': 'Alle {count} Änderungen zeigen',
|
||||
'assistant.week.showAll.one': '{count} Änderung zeigen',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user