From b4eae5b7a13e5e92b86a54f5137c941fddc4b9e2 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 28 Oct 2026 10:30:31 +0000 Subject: [PATCH] feat(yasgui): redesign the settings and make request settings global The settings follow the settings window of textfold: pages grouped in the navigation with icons, each setting a row with one line about it and its control on the right, a fixed header and footer, and changes that apply at once (the footer says "Saved"; Done closes). A new General page holds the theme (Light, Dark or System), the editor themes, the request settings and what happens before a query runs. Request method, Accept headers and custom HTTP headers now apply to every tab, including tabs opened later. The formatter, auto-format and the CONSTRUCT check apply to every tab too and are kept across reloads. Settings > AI keeps the services as cards and gets a form with a provider list that fills in the base URL (still editable, with a reset), a link to get an API key, and Fetch models with a searchable model list. When a browser can't read a model list (CORS), the provider's known models are offered and the form explains what to do. --- .changeset/settings-redesign.md | 9 + docs/developer-guide.md | 4 +- docs/user-guide.md | 84 +- packages/yasgui/src/PersistentConfig.ts | 34 + packages/yasgui/src/Tab.ts | 16 + packages/yasgui/src/TabSettingsModal.scss | 529 ++++++-- packages/yasgui/src/TabSettingsModal.ts | 1087 ++++++++--------- packages/yasgui/src/ThemeManager.ts | 12 + packages/yasgui/src/ai/AiPanel.scss | 607 ++++++++- packages/yasgui/src/ai/AiSettingsForm.ts | 911 ++++++++------ packages/yasgui/src/ai/Combobox.ts | 278 +++++ packages/yasgui/src/ai/openaiClient.ts | 27 +- packages/yasgui/src/ai/providers.ts | 219 +++- packages/yasgui/src/ai/types.ts | 2 + packages/yasgui/src/index.ts | 14 +- .../src/queryManagement/QueryBrowser.ts | 11 +- packages/yasgui/src/settingsUi.ts | 157 +++ test/unit/ai-assistant-test.ts | 32 + 18 files changed, 2841 insertions(+), 1192 deletions(-) create mode 100644 .changeset/settings-redesign.md create mode 100644 packages/yasgui/src/ai/Combobox.ts create mode 100644 packages/yasgui/src/settingsUi.ts diff --git a/.changeset/settings-redesign.md b/.changeset/settings-redesign.md new file mode 100644 index 00000000..b1e1a71c --- /dev/null +++ b/.changeset/settings-redesign.md @@ -0,0 +1,9 @@ +--- +"@matdata/yasgui": minor +--- + +Redesign the settings, after the settings window of textfold. The pages are grouped in the navigation (Querying, Your data, Assistant, Help) with an icon each, and each setting is a row with one line about it and its control on the right (switches, segmented choices, selects). The header and footer stay in place while the page scrolls. Changes apply at once: the Save and Cancel buttons are gone, the footer says "Saved" after a change, and Done closes the settings. + +The new **General** page holds the theme (Light, Dark or System), the editor themes, the request settings and what happens before a query runs. The request method, Accept headers and custom HTTP headers now apply to every tab, including tabs opened later (stored as `requestSettings` in the persisted config); the formatter, auto-format and the CONSTRUCT check now apply to every tab too, and are kept across reloads. `tab.openSettings("request")` still works and opens General. + +Settings → AI: services stay cards, with the default one selected. Adding or editing a service now starts with a **Provider** list (searchable, grouped); the provider fills in the base URL, which can still be changed (marked "Changed", with a reset) and links to the page where users get an API key. **Fetch models** replaces Test connection and fills a searchable **Model** list, with the provider's recommended models first and any model id accepted. When a browser can't read a service's model list (CORS), MatGUI says so, offers the models it knows for that provider, and explains what to do (a proxy, OpenRouter, or the CORS setting of Ollama or LM Studio). Services remember their provider (`providerId`). diff --git a/docs/developer-guide.md b/docs/developer-guide.md index d6ed375d..e5b13c67 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -712,7 +712,7 @@ interface Config { showSnippetsBar?: boolean; // default: false // Tooltip with the label, description and type of an IRI hovered in the editor, - // fetched from the tab's endpoint. Users can also switch it off in Settings → Editor + // fetched from the tab's endpoint. Users can also switch it off in Settings → General iriHover?: boolean; // default: true // Look up IRIs the endpoint doesn't describe in LOV (lov.linkeddata.es) @@ -2574,7 +2574,7 @@ const id = tab.getId(); ##### `openSettings(section?: string): void` -Open the settings modal of this tab, optionally on a section (for example `"endpoints"`). +Open the settings modal of this tab, optionally on a page: `"general"`, `"endpoints"`, `"prefix"`, `"editor"`, `"workspaces"`, `"importexport"`, `"ai"`, `"shortcuts"` or `"about"`. `"request"`, the name of an earlier page, opens `"general"`. ```javascript tab.openSettings("endpoints"); diff --git a/docs/user-guide.md b/docs/user-guide.md index 6a914942..ca55fa7b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -363,7 +363,7 @@ Click **Search queries and commands** in the top bar, or press `Ctrl+K` (`⌘K` - **Queries**: the saved queries of all your workspaces, with the workspace and folder they are in. Pick one to open it in a new tab, or to go to the tab that already has it open - **Snippets**: the code snippets of the [Insert menu](#insert-menu), found by name, group or the code they write (`filter` finds the comparison snippets). Pick one to insert it at the cursor - **Commands**: Run query, New tab, Insert snippet, Insert PREFIX declarations, Switch endpoint, Explore endpoint, Show saved queries, Toggle side panel, Toggle side-by-side layout, Format, Share, Save, Download, full screen editor, dark mode and the documentation. Commands with a shortcut show it at the right -- **Settings**: every section of the settings (Request, SPARQL endpoints, Workspaces, Prefixes, Editor, Import / export, Keyboard shortcuts, About), for example type `prefixes` and pick **Settings: Prefixes** +- **Settings**: every page of the settings (General, SPARQL endpoints, Workspaces, Prefixes, Editor, AI, Import / export, Keyboard shortcuts, About), for example type `prefixes` and pick **Settings: Prefixes** Type `>` first to search commands and settings only. Snippets and settings show up once you type something. Use `↑` and `↓` to move, `Enter` to open and `Esc` to close. @@ -503,10 +503,10 @@ Dark Theme characteristics: You can customize the syntax highlighting theme separately for light and dark modes: 1. Click the Settings button (gear icon) at the right of the top bar -2. Navigate to the "Editor" tab -3. Select your preferred theme for light mode from the dropdown -4. Select your preferred theme for dark mode from the dropdown -5. Click "Save" to apply your changes +2. On the **General** page, under **Appearance**, pick a theme for light mode and one for dark mode in **Editor theme** +3. The change applies at once + +**Theme** on the same page picks Light, Dark or System; System follows the color scheme of your computer. **Available Themes:** The editor is built on CodeMirror 6 and ships with the following syntax highlighting themes: @@ -577,10 +577,10 @@ Keep your SPARQL queries clean and readable with automatic formatting. **Configuration:** -- Open Settings (gear icon in the top bar) to: - - Select your preferred formatter - - Enable/disable auto-format on query execution - - Both settings are saved persistently +- Open Settings (gear icon in the top bar): + - **Editor → Query formatter** selects the formatter + - **General → Format the query** switches auto-format on query execution on or off + - Both settings apply to every tab and are saved in your browser ### CONSTRUCT Query Validation @@ -615,10 +615,10 @@ WHERE { **Configuration:** -- Open Settings (gear icon in the top bar) → Editor tab -- Toggle "Validate CONSTRUCT query variables" checkbox +- Open Settings (gear icon in the top bar) → General +- Switch **Check CONSTRUCT variables** on or off - Enabled by default -- Setting is saved persistently +- The setting applies to every tab and is saved in your browser **Why This Helps:** @@ -704,8 +704,8 @@ MatGUI includes standard prefixes like: **How to Use:** 1. Click **Prefixes** in the editor toolbar to insert all saved prefixes -2. Open Settings (gear icon in the top bar) to manage your prefix collection -3. Enable/disable auto-capture in Settings +2. Open **Settings → Prefixes** to manage your prefix collection; changes are stored as you type +3. Switch **Capture new prefixes from queries** on or off on the same page Prefixes are added below the comment lines a query starts with, so a leading comment such as `#defaultView:Geo` (see [Plugin Selection](#plugin-selection)) stays on top. @@ -920,7 +920,7 @@ The information is fetched with a small query on the endpoint of the tab, using Prefixed names whose prefix isn't declared in the query get no tooltip. The tooltip also reminds you that Ctrl+click and Ctrl+Shift+click open the [URI Explorer](#uri-explorer). -To switch it off, untick **Show IRI information on hover** in **Settings → Editor**. +To switch it off, turn off **IRI information on hover** in **Settings → General**. ### Endpoint Overview @@ -993,12 +993,12 @@ Queries in an answer get buttons: **Use in editor** replaces your query (press ` The assistant works with any service that offers an OpenAI-compatible API. Open **Settings → AI** (or the gear in the AI view). Under **AI services** you see: - **The AI service of the site**, when the site you use MatGUI on provides one (it may offer more than one). You need no key of your own. -- **Your own services.** Click **Add service**, pick a provider in the **Base URL** list or enter another URL, and fill in your **API key** and the **Model**. **Name** is how the service is shown in the AI view; it defaults to the provider or host. Use **Test connection** to check the key and to list the models of the service. The key is optional: local models and some proxies need none. Add as many services as you like, and change or remove them with **Edit** and **Remove**. +- **Your own services.** Click **Add AI service** and pick a **Provider**. MatGUI fills in its **Base URL**; you can still change it, for example for a gateway or a proxy (the field then says **Changed**, and **Reset to provider default** brings the provider's URL back). Pick **Custom** for any other OpenAI-compatible service. Paste your **API key**: the link next to it opens the page of the provider where you create one. Click **Fetch models** to check the key and list the models of the service, and pick one in **Model**; recommended models come first, with a star, and you can also type any model id. **Name** is how the service is shown in the AI view; it defaults to the provider. Click **Save service**. Local models (Ollama, LM Studio) need no key, and the key field is left out for them. Add as many services as you like, and change or remove them with **Edit** and **Remove**. - **No AI service**: nothing is sent anywhere. See "Without an AI service" below. -Select the service the assistant uses by default and click **Save**. In the AI view, the service name at the top is a list: pick another service there to use it for your next questions, until you reload the page. "No AI service: copy a prompt" in that list makes the actions copy prompts instead. +Select the service the assistant uses by default; it applies at once. **Make this the default service** in the form of a service does the same. In the AI view, the service name at the top is a list: pick another service there to use it for your next questions, until you reload the page. "No AI service: copy a prompt" in that list makes the actions copy prompts instead. -The Base URL list includes OpenAI, Anthropic (Claude), Google Gemini, Mistral, Moonshot (Kimi), DeepSeek, xAI (Grok), OpenRouter, Groq, Together AI, Fireworks AI, Cerebras, Hugging Face, Scaleway, Azure OpenAI, Ollama and LM Studio. Some of them: +The Provider list includes OpenAI, Anthropic (Claude), Google Gemini, Mistral, Moonshot (Kimi), DeepSeek, xAI (Grok), OpenRouter, Groq, Together AI, Fireworks AI, Cerebras, Hugging Face, Scaleway, Azure OpenAI, Ollama and LM Studio. Some of them: | Service | Base URL | | ------------------------- | ---------------------------------------------------------- | @@ -1016,14 +1016,14 @@ The Base URL list includes OpenAI, Anthropic (Claude), Google Gemini, Mistral, M - **Anthropic**: MatGUI adds the header Anthropic asks of browser requests (`anthropic-dangerous-direct-browser-access`) and uses its OpenAI-compatible endpoint, with Claude model names such as `claude-sonnet-4-5`. - **Azure OpenAI**: the key is sent in the `api-key` header, as Azure expects. The model is the name of your deployment. - The service must accept requests from a browser (CORS). OpenAI, Anthropic, Google Gemini and OpenRouter do; when **Test connection** can't reach another service, that service probably doesn't, and the AI service of the site or a local model is the way to go. Models on your own computer need one setting: + The service must accept requests from a browser (CORS). OpenAI, Anthropic, Google Gemini and OpenRouter do. When **Fetch models** can't reach a service, it probably doesn't let a browser read its model list: MatGUI then offers the models it knows for that provider, and you can type another model id. If questions to the assistant fail the same way, the AI service of the site, OpenRouter or a local model is the way to go. Models on your own computer need one setting: - **Ollama** only accepts the MatGUI site when it is started with `OLLAMA_ORIGINS` set to that site, for example `OLLAMA_ORIGINS=https://matgui.matdata.eu ollama serve`. - **LM Studio**: turn on **Enable CORS** in the settings of its local server. - Your browser may ask whether the site may access devices on your local network. Allow it, or requests to `localhost` fail. Small local models often can't run check queries (tool calls). MatGUI then asks again without them, so the answer comes without checks on the endpoint. -Your API keys are stored in this browser only (in its local storage). Each key is sent only to the base URL of its service, and it is never included in exported settings ([Configuration Import/Export](#configuration-importexport)) or share links. Anyone who uses the same browser profile can read it, so use a key with a spending limit, and click **Forget key** on a shared computer. +Your API keys are stored in this browser only (in its local storage). Each key is sent only to the base URL of its service, and it is never included in exported settings ([Configuration Import/Export](#configuration-importexport)) or share links. Anyone who uses the same browser profile can read it, so use a key with a spending limit, and click **Forget key** (under the key, in the form of the service) on a shared computer. **Without an AI service: Copy for AI** @@ -1031,7 +1031,7 @@ You don't need an API key to get help. Without an AI service, the actions of the **What is sent, and to whom** -Nothing is sent until you click an action or send a question. Before the first request to a service, the AI view shows what the request includes and where it goes, and asks you to confirm. Tick **Don't ask again** to skip this for that service; **Ask me again** in **Settings → AI** brings the question back. +Nothing is sent until you click an action or send a question. Before the first request to a service, the AI view shows what the request includes and where it goes, and asks you to confirm. Tick **Don't ask again** to skip this for that service; **Ask again** in **Settings → AI** brings the question back. | Action | Sent to the AI service | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | @@ -1124,23 +1124,17 @@ Access comprehensive configuration options through the Settings modal. - Or search for a section with `Ctrl+K`, for example **Settings: Prefixes** - **Manage endpoints** in the endpoint dropdown opens the Endpoints section directly -**Settings Sections:** - -**Prefixes Tab:** +The pages are grouped in the navigation on the left: **Querying** (General, Endpoints, Prefixes, Editor), **Your data** (Workspaces, Import / export), **Assistant** (AI) and **Help** (Keyboard shortcuts, About). Each setting is a row with a short explanation and its control on the right. -- Manage saved prefix declarations -- Enable/disable auto-capture -- Add, edit, or remove prefixes +Changes apply at once: there is no Save button. The footer shows **Saved** after a change, and **Done** (or `Esc`) closes the settings. Forms that need several fields together, such as an AI service or the authentication of an endpoint, have their own Save button. -**Request Configuration Tab:** +**General:** -- HTTP request method (GET/POST) -- Accept headers -- Custom request parameters -- Named/default graphs configuration -- Custom HTTP headers +- **Appearance**: the theme (Light, Dark or System) and the editor theme for light and for dark mode +- **Requests**: the request method (POST or GET), the results format (Accept header of SELECT and ASK queries), the graph format (Accept header of CONSTRUCT and DESCRIBE queries) and custom HTTP headers. These apply to every tab, including tabs you open later +- **Before running a query**: format the query, check CONSTRUCT variables, and show IRI information on hover (see [IRI Hover Info](#iri-hover-info)) -**Endpoints Tab:** +**Endpoints:** - **Developer-Configured Endpoint Buttons**: Enable/disable developer-configured buttons - **SPARQL Endpoints**: Manage all your endpoints in one place @@ -1148,23 +1142,19 @@ Access comprehensive configuration options through the Settings modal. - Add labels and add endpoints to the Quick switch group of the endpoint dropdown - Automatically tracks accessed endpoints -**Formatting Tab:** +**Prefixes:** -- Select formatter (sparql-formatter or legacy) -- Enable/disable auto-format on query execution +- Manage saved prefix declarations, stored as you type +- Capture new prefixes from the queries you run -**Editor Tab:** +**Editor:** -- Select CodeMirror theme for light mode -- Select CodeMirror theme for dark mode -- Switch between the Default and GitHub Dark editor themes in the Settings → Editor tab -- Includes custom "GitHub Dark" theme matching GitHub's dark default colors -- Show IRI information on hover (see [IRI Hover Info](#iri-hover-info)) +- The query formatter (SPARQL 1.2 formatter or the legacy one), used by Format and before running a query -**AI Tab:** +**AI:** -- Choose the AI service: the service of the site (when it provides one), your own API key, or none ("Copy for AI" only) -- Base URL, API key and model of your own service, with **Test connection** and **Forget key** +- Your AI services as cards: the service of the site (when it provides one), your own services, and "No AI service" ("Copy for AI" only). The selected card is the default +- **Add AI service**: provider, base URL (filled in from the provider), API key with a link to get one, and a model fetched from the service - Let the assistant run check queries on the endpoint - Bring back the privacy notice for services you chose not to be asked about again @@ -2697,7 +2687,7 @@ The proxy solves this by: 1. **No AI service set up**: without a service, the actions copy a prompt ([Copy for AI](#ai-assistant)). Add a service in **Settings → AI**, or pick one in the list at the top of the AI view. 2. **Key refused (401)**: check the API key in **Settings → AI**, and that it belongs to the service of the base URL. -3. **Model not found (404)**: use **Test connection** and pick a model from the list. +3. **Model not found (404)**: use **Fetch models** in **Settings → AI** and pick a model from the list. 4. **Could not reach the service**: check the base URL (it usually ends in `/v1`). The service must allow requests from a browser (CORS); for Ollama, start it with `OLLAMA_ORIGINS` set to the MatGUI site. 5. **Invented names**: keep **check queries** switched on, open the [Endpoint Overview](#endpoint-overview) and run its Classes and Properties queries so the assistant knows more of the endpoint, or name the vocabulary in your question. diff --git a/packages/yasgui/src/PersistentConfig.ts b/packages/yasgui/src/PersistentConfig.ts index 6cb97e99..b8dcfb21 100644 --- a/packages/yasgui/src/PersistentConfig.ts +++ b/packages/yasgui/src/PersistentConfig.ts @@ -23,6 +23,21 @@ export interface PersistedJson { codeMirrorThemeLight?: string; // User-selected CodeMirror theme for light mode codeMirrorThemeDark?: string; // User-selected CodeMirror theme for dark mode sidePanel?: SidePanelState; // Left side panel (Queries / Endpoint / AI) + requestSettings?: RequestSettings; // Request settings of Settings → General, shared by all tabs + editorSettings?: EditorSettings; // Formatter and checks of Settings → General and Editor, shared by all tabs +} +/** Settings of the query editor that apply to every tab */ +export interface EditorSettings { + formatterType?: "sparql-formatter" | "legacy"; + autoformatOnQuery?: boolean; + checkConstructVariables?: boolean; +} +/** The request settings users change in Settings → General; they apply to every tab */ +export interface RequestSettings { + method?: "GET" | "POST"; + acceptHeaderSelect?: string; + acceptHeaderGraph?: string; + headers?: { [name: string]: string }; } export interface SidePanelState { open: boolean; @@ -193,6 +208,25 @@ export default class PersistentConfig { this.toStorage(); } + /** The request settings shared by all tabs, or undefined when the user never changed them */ + public getRequestSettings(): RequestSettings | undefined { + return this.persistedJson.requestSettings; + } + + public setRequestSettings(settings: RequestSettings) { + this.persistedJson.requestSettings = { ...this.persistedJson.requestSettings, ...settings }; + this.toStorage(); + } + + public getEditorSettings(): EditorSettings { + return this.persistedJson.editorSettings || {}; + } + + public setEditorSettings(settings: EditorSettings) { + this.persistedJson.editorSettings = { ...this.persistedJson.editorSettings, ...settings }; + this.toStorage(); + } + public getIriHoverEnabled(): boolean { return this.persistedJson.iriHoverEnabled !== false; } diff --git a/packages/yasgui/src/Tab.ts b/packages/yasgui/src/Tab.ts index b5f7b5a4..5f91f898 100644 --- a/packages/yasgui/src/Tab.ts +++ b/packages/yasgui/src/Tab.ts @@ -881,6 +881,21 @@ export class Tab extends EventEmitter { return undefined; } + /** Apply the editor settings shared by all tabs (Settings → General and Editor) to the query editor */ + public applyEditorSettings() { + if (!this.yasqe) return; + const settings = this.yasgui.persistentConfig.getEditorSettings(); + if (this.yasqe.persistentConfig) { + if (settings.formatterType) this.yasqe.persistentConfig.formatterType = settings.formatterType; + if (settings.autoformatOnQuery !== undefined) { + this.yasqe.persistentConfig.autoformatOnQuery = settings.autoformatOnQuery; + } + } + if (settings.checkConstructVariables !== undefined) { + this.yasqe.setCheckConstructVariables(settings.checkConstructVariables); + } + } + /** Open the settings modal of this tab, optionally on a section (e.g. "endpoints") */ public openSettings(section?: string) { this.settingsModal?.open(section); @@ -1488,6 +1503,7 @@ export class Tab extends EventEmitter { throw new Error("Expected a wrapper element before instantiating yasqe"); } this.yasqe = new Yasqe(this.yasqeWrapperEl, yasqeConf); + this.applyEditorSettings(); // Hook up the save button to managed query save this.yasqe.on("saveManagedQuery", () => { diff --git a/packages/yasgui/src/TabSettingsModal.scss b/packages/yasgui/src/TabSettingsModal.scss index 9a98b46e..b5b28a32 100644 --- a/packages/yasgui/src/TabSettingsModal.scss +++ b/packages/yasgui/src/TabSettingsModal.scss @@ -25,116 +25,164 @@ left: 0; right: 0; bottom: 0; - background: var(--yasgui-overlay-bg, rgba(0, 0, 0, 0.5)); + background: var(--yasgui-scrim, rgba(0, 0, 0, 0.5)); z-index: 10000; align-items: center; justify-content: center; + padding: 24px; + box-sizing: border-box; &.open { display: flex; } } -// The modal is appended to the page body, outside .yasgui, so it sets the Yasgui font itself +// The modal is appended to the page body, outside .yasgui, so it sets the Yasgui font itself. +// Title and close at the top and a note and Done at the foot stay in place; only the page between scrolls. .tabSettingsModal { font-family: var(--yasgui-font-family, system-ui, sans-serif); font-size: var(--yasgui-font-size, 13px); - background: var(--yasgui-bg-primary, white); + line-height: 1.45; + color: var(--yasgui-ink, #1c2633); + background: var(--yasgui-surface-raised, white); // Low specificity, so monospace fields such as .prefixTextarea keep their font :where(input, select, textarea, button) { font-family: inherit; } - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); - max-width: 1000px; - width: 90%; - max-height: 90vh; + border-radius: 14px; + box-shadow: var(--yasgui-shadow-overlay, 0 4px 20px rgba(0, 0, 0, 0.3)); + width: min(920px, 100%); + height: min(760px, calc(100vh - 48px)); display: flex; flex-direction: column; overflow: hidden; + outline: none; } .modalHeader { display: flex; justify-content: space-between; align-items: center; - padding: 20px; - border-bottom: 1px solid var(--yasgui-border-color, #e0e0e0); + gap: 12px; + padding: 12px 12px 12px 22px; + border-bottom: 1px solid var(--yasgui-line-soft, #edf0f4); + flex: none; h2 { margin: 0; - font-size: 20px; - font-weight: 600; - color: var(--yasgui-text-primary, #000); + font-size: 16px; + font-weight: 650; + color: var(--yasgui-ink, #1c2633); } .closeButton { + display: grid; + place-items: center; background: none; border: none; - font-size: 32px; - line-height: 1; cursor: pointer; - color: var(--yasgui-text-secondary, #666); + color: var(--yasgui-ink-subtle, #6a7380); padding: 0; width: 32px; height: 32px; + border-radius: var(--yasgui-radius-button, 8px); &:hover { - color: var(--yasgui-text-primary, #333); + color: var(--yasgui-ink, #1c2633); + background: var(--yasgui-surface-sunken, #edf1f6); } } } .modalBody { flex: 1; + min-height: 0; display: flex; overflow: hidden; } +// Navigation: quiet, grouped, an icon per page .modalSidebar { - width: 200px; - min-width: 200px; - border-right: 1px solid var(--yasgui-border-color, #e0e0e0); - background: var(--yasgui-bg-secondary, #f8f9fa); + width: 212px; + min-width: 212px; + box-sizing: border-box; + padding: 10px 10px 16px; + border-right: 1px solid var(--yasgui-line-soft, #edf0f4); + background: var(--yasgui-surface, #f1f4f8); display: flex; flex-direction: column; + gap: 1px; overflow-y: auto; } -.modalContentArea { - flex: 1; - overflow-y: auto; - padding: 20px; +.modalNavGroup { + margin: 14px 10px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--yasgui-ink-subtle, #6a7380); + + &:first-child { + margin-top: 4px; + } } .modalNavButton { + display: flex; + align-items: center; + gap: 10px; background: none; border: none; - padding: 12px 20px; - font-size: 14px; + border-radius: var(--yasgui-radius-button, 8px); + padding: 7px 10px; + font-size: 13.5px; font-weight: 500; cursor: pointer; - color: var(--yasgui-text-secondary, #666); + color: var(--yasgui-ink-muted, #56606c); text-align: left; - border-left: 3px solid transparent; - transition: all 0.2s; white-space: nowrap; + svg { + flex: none; + color: var(--yasgui-ink-subtle, #6a7380); + } + &:hover { - background: var(--yasgui-bg-primary, white); - color: var(--yasgui-accent-color, #337ab7); + color: var(--yasgui-ink, #1c2633); + background: var(--yasgui-surface-selected, #e6ecf3); + } + + &:focus-visible { + outline: 2px solid var(--yasgui-focus, #044a8c); + outline-offset: -2px; } &.active { - color: var(--yasgui-accent-color, #337ab7); - background: var(--yasgui-bg-primary, white); - border-left-color: var(--yasgui-accent-color, #337ab7); - font-weight: 600; + color: var(--yasgui-ink, #1c2633); + background: var(--yasgui-surface-raised, white); + box-shadow: var(--yasgui-shadow-raise, 0 0 0 1px #dfe5ec); + + svg { + color: var(--yasgui-brand, #0563bb); + } } } +[data-theme="dark"] .modalNavButton.active { + background: var(--yasgui-surface-selected, #232b36); +} + +.modalContentArea { + flex: 1; + min-width: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 6px 26px 26px; +} + .modalTabContent { display: none; @@ -143,9 +191,184 @@ } } +// A group of settings: a small heading, an optional line under it, and a list of rows +.settingsGroup { + margin-top: 22px; + + &:first-child { + margin-top: 14px; + } +} + +.settingsGroupTitle { + margin: 0 0 2px; + font-size: 11.5px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--yasgui-ink-subtle, #6a7380); +} + +.settingsGroupLede { + margin: 2px 0 8px; + max-width: 600px; + font-size: 12.5px; + color: var(--yasgui-ink-subtle, #6a7380); +} + +.settingsList > * + * { + border-top: 1px solid var(--yasgui-line-soft, #edf0f4); +} + +.settingsRow { + padding: 9px 0; +} + +.settingsRowMain { + display: flex; + align-items: center; + gap: 14px; + min-height: 34px; +} + +.settingsRowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.settingsRowTitle { + font-weight: 600; + color: var(--yasgui-ink, #1c2633); +} + +.settingsRowDescription { + font-size: 12.5px; + color: var(--yasgui-ink-subtle, #6a7380); + overflow-wrap: anywhere; +} + +.settingsRowControls { + flex: none; + display: flex; + align-items: center; + gap: 8px; + + .settingsSelect { + width: auto; + min-width: 170px; + } +} + +.settingsRowEditor { + margin-top: 10px; +} + +.settingsRowEditor[hidden] { + display: none; +} + +// An on/off switch: a checkbox drawn as a track with a knob +.settingsSwitch { + position: relative; + display: inline-flex; + flex: none; + width: 34px; + height: 20px; + + input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; + z-index: 1; + } + + .settingsSwitchTrack { + width: 100%; + height: 100%; + border-radius: 10px; + background: var(--yasgui-line-grip, #c9d2dc); + transition: background 0.15s; + + &::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); + transition: transform 0.15s; + } + } + + input:checked + .settingsSwitchTrack { + background: var(--yasgui-brand-fill, #0563bb); + + &::after { + transform: translateX(14px); + } + } + + input:focus-visible + .settingsSwitchTrack { + outline: 2px solid var(--yasgui-focus, #044a8c); + outline-offset: 2px; + } +} + +// Two or three choices side by side +.settingsSegmented { + display: inline-flex; + padding: 2px; + border-radius: 9px; + border: 1px solid var(--yasgui-line, #dfe5ec); + background: var(--yasgui-surface-sunken, #edf1f6); + + button { + border: 0; + background: none; + border-radius: 7px; + padding: 4px 11px; + font-size: 13px; + font-weight: 500; + color: var(--yasgui-ink-muted, #56606c); + cursor: pointer; + + &:hover { + color: var(--yasgui-ink, #1c2633); + } + + &.active { + color: var(--yasgui-ink, #1c2633); + background: var(--yasgui-surface-raised, white); + box-shadow: var(--yasgui-shadow-raise, 0 0 0 1px #dfe5ec); + } + + &:focus-visible { + outline: 2px solid var(--yasgui-focus, #044a8c); + } + } +} + +[data-theme="dark"] .settingsSegmented button.active { + background: var(--yasgui-surface-selected, #232b36); +} + .settingsSection { margin-bottom: 20px; + &:first-child { + margin-top: 14px; + } + h3 { margin: 0 0 12px 0; font-size: 16px; @@ -154,19 +377,21 @@ } } +// Headings of the pages that are not built from rows (endpoints, workspaces, import / export, …) .settingsLabel { - font-weight: 600; - margin-bottom: 8px; - font-size: 14px; display: block; - color: var(--yasgui-text-primary, #000); + margin-bottom: 4px; + font-size: 11.5px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--yasgui-ink-subtle, #6a7380); } .settingsHelp { - font-size: 12px; - color: var(--yasgui-text-secondary, #666); + font-size: 12.5px; + color: var(--yasgui-ink-subtle, #6a7380); margin-bottom: 10px; - font-style: italic; } .settingsLink { @@ -285,20 +510,24 @@ .settingsSelect { width: 100%; - padding: 8px; - border: 1px solid var(--yasgui-input-border, #ccc); - border-radius: 4px; - font-size: 14px; - background-color: var(--yasgui-bg-secondary, white); - color: var(--yasgui-text-primary, #000); + padding: 6px 28px 6px 10px; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: var(--yasgui-radius-button, 8px); + font-size: 13.5px; + background-color: var(--yasgui-surface-raised, white); + color: var(--yasgui-ink, #1c2633); &:focus { outline: none; - border-color: var(--yasgui-input-focus, #337ab7); - box-shadow: 0 0 0 2px rgba(51, 122, 183, 0.1); + border-color: var(--yasgui-brand, #0563bb); + box-shadow: 0 0 0 3px var(--yasgui-brand-soft, #e3eefa); } } +[data-theme="dark"] .settingsSelect { + background-color: var(--yasgui-surface-sunken, #1c222b); +} + .keyValueContainer { display: flex; flex-direction: column; @@ -313,66 +542,73 @@ .keyInput, .valueInput { - padding: 8px; - border: 1px solid var(--yasgui-input-border, #ccc); - border-radius: 4px; - font-size: 14px; - background-color: var(--yasgui-bg-secondary, white); - color: var(--yasgui-text-primary, #000); + padding: 6px 10px; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: var(--yasgui-radius-button, 8px); + font-size: 13px; + font-family: var(--yasgui-font-mono, monospace); + background-color: var(--yasgui-surface-raised, white); + color: var(--yasgui-ink, #1c2633); &:focus { outline: none; - border-color: var(--yasgui-input-focus, #337ab7); - box-shadow: 0 0 0 2px rgba(51, 122, 183, 0.1); + border-color: var(--yasgui-brand, #0563bb); + box-shadow: 0 0 0 3px var(--yasgui-brand-soft, #e3eefa); } &::placeholder { - color: var(--yasgui-text-secondary, #999); + font-family: var(--yasgui-font-family, sans-serif); + color: var(--yasgui-ink-subtle, #6a7380); } } .removeButton { - padding: 6px 10px; - background-color: var(--yasgui-danger-color, #d9534f); - color: white; + display: grid; + place-items: center; + width: 30px; + height: 30px; + padding: 0; + background: none; + color: var(--yasgui-ink-subtle, #6a7380); border: none; - border-radius: 4px; + border-radius: var(--yasgui-radius-button, 8px); cursor: pointer; - font-size: 14px; - transition: background-color 0.2s; &:hover { - background-color: var(--yasgui-danger-hover, #c9302c); + color: var(--yasgui-danger, #b42318); + background: var(--yasgui-surface-sunken, #edf1f6); } - &:focus { - outline: none; - box-shadow: 0 0 0 2px rgba(217, 83, 79, 0.3); - } - - i { - pointer-events: none; + &:focus-visible { + outline: 2px solid var(--yasgui-focus, #044a8c); } } } +[data-theme="dark"] .keyValueRow :is(.keyInput, .valueInput), +[data-theme="dark"] .prefixTextarea { + background-color: var(--yasgui-surface-sunken, #1c222b); +} + .prefixTextarea { width: 100%; - padding: 10px; - border: 1px solid var(--yasgui-input-border, #ccc); - border-radius: 4px; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; - font-size: 13px; + margin-top: 6px; + padding: 10px 12px; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: var(--yasgui-radius-md, 10px); + font-family: var(--yasgui-font-mono, monospace); + font-size: 12.5px; + line-height: 1.6; resize: vertical; box-sizing: border-box; - min-height: 200px; - background-color: var(--yasgui-bg-secondary, white); - color: var(--yasgui-text-primary, #000); + min-height: 220px; + background-color: var(--yasgui-surface-raised, white); + color: var(--yasgui-ink, #1c2633); &:focus { outline: none; - border-color: var(--yasgui-input-focus, #337ab7); - box-shadow: 0 0 0 2px rgba(51, 122, 183, 0.1); + border-color: var(--yasgui-brand, #0563bb); + box-shadow: 0 0 0 3px var(--yasgui-brand-soft, #e3eefa); } } @@ -452,39 +688,94 @@ } .modalFooter { - padding: 15px 20px; - border-top: 1px solid var(--yasgui-border-color, #e0e0e0); + flex: none; + padding: 12px 14px 12px 22px; + border-top: 1px solid var(--yasgui-line-soft, #edf0f4); display: flex; - justify-content: flex-end; - gap: 10px; + align-items: center; + gap: 16px; +} + +.modalFooterNote { + flex: 1; + margin: 0; + font-size: 12.5px; + color: var(--yasgui-ink-subtle, #6a7380); +} + +// "Saved", for a moment after a change was stored +.modalSavedStatus { + display: none; + align-items: center; + gap: 6px; + margin-right: 8px; + font-weight: 500; + color: var(--yasgui-success, #067647); + + &::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + } + + &.visible { + display: inline-flex; + } } .primaryButton, .secondaryButton, .dangerButton { - padding: 8px 20px; - border: none; - border-radius: 4px; - font-size: 14px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border: 1px solid transparent; + border-radius: var(--yasgui-radius-button, 8px); + font-size: 13px; + font-weight: 500; cursor: pointer; - transition: background-color 0.2s; + white-space: nowrap; + transition: background-color 0.15s; + + &:disabled { + opacity: 0.6; + cursor: default; + } + + &:focus-visible { + outline: 2px solid var(--yasgui-focus, #044a8c); + outline-offset: 2px; + } } .primaryButton { - background: var(--yasgui-accent-color, #337ab7); - color: white; + background: var(--yasgui-brand-fill, #0563bb); + color: var(--yasgui-on-brand, white); + font-weight: 600; &:hover { - background: var(--yasgui-link-hover, #286090); + background: var(--yasgui-brand-fill-strong, #044a8c); } } .secondaryButton { - background: var(--yasgui-bg-tertiary, #e0e0e0); - color: var(--yasgui-text-primary, #333); + background: var(--yasgui-surface-raised, white); + border-color: var(--yasgui-line, #dfe5ec); + color: var(--yasgui-ink, #1c2633); + + &:hover { + background: var(--yasgui-surface-sunken, #edf1f6); + } +} + +[data-theme="dark"] .secondaryButton { + background: var(--yasgui-surface-sunken, #1c222b); &:hover { - background: var(--yasgui-bg-quaternary, #d0d0d0); + background: var(--yasgui-surface-selected, #232b36); } } @@ -1333,3 +1624,43 @@ display: none !important; } } + +// Small screens: the settings fill the screen, the navigation becomes a row of pages above the page +@media (max-width: 720px) { + .tabSettingsModalOverlay { + padding: 0; + } + + .tabSettingsModal { + width: 100%; + height: 100%; + border-radius: 0; + } + + .modalBody { + flex-direction: column; + } + + .modalSidebar { + width: auto; + min-width: 0; + flex-direction: row; + flex: none; + overflow-x: auto; + padding: 8px 10px; + border-right: 0; + border-bottom: 1px solid var(--yasgui-line-soft, #edf0f4); + } + + .modalNavGroup { + display: none; + } + + .modalContentArea { + padding: 4px 16px 20px; + } + + .settingsRowMain { + flex-wrap: wrap; + } +} diff --git a/packages/yasgui/src/TabSettingsModal.ts b/packages/yasgui/src/TabSettingsModal.ts index d00d9ebc..3f35ceb2 100644 --- a/packages/yasgui/src/TabSettingsModal.ts +++ b/packages/yasgui/src/TabSettingsModal.ts @@ -9,6 +9,8 @@ import { deduplicatePrefixes } from "./prefixUtils"; import { WorkspaceSettingsForm } from "./queryManagement/WorkspaceSettingsForm"; import { icon, IconName } from "./icons"; import AiSettingsForm from "./ai/AiSettingsForm"; +import type { EditorSettings, RequestSettings } from "./PersistentConfig"; +import { controlId, settingsGroup, settingsRow, settingsSegmented, settingsSelect, settingsSwitch } from "./settingsUi"; const AcceptOptionsMap: { key: string; value: string }[] = [ { key: "JSON", value: "application/sparql-results+json" }, @@ -30,6 +32,31 @@ const AcceptHeaderGraphMap: { key: string; value: string }[] = [ // Type for key-value pairs (headers, args, etc.) type KeyValuePair = { name: string; value: string }; +// Editor themes: each is a `cm-s-` class applied to the CodeMirror 6 editor root; the styles ship with yasgui +const EDITOR_THEMES: { value: string; label: string; mode: "light" | "dark" }[] = [ + { value: "default", label: "Default", mode: "light" }, + { value: "eclipse", label: "Eclipse", mode: "light" }, + { value: "idea", label: "IntelliJ IDEA", mode: "light" }, + { value: "solarized-light", label: "Solarized Light", mode: "light" }, + { value: "github-dark", label: "GitHub Dark", mode: "dark" }, + { value: "monokai", label: "Monokai", mode: "dark" }, + { value: "dracula", label: "Dracula", mode: "dark" }, + { value: "material-darker", label: "Material Darker", mode: "dark" }, +]; + +/** A page of the settings and its place in the navigation */ +interface SettingsPage { + id: string; + label: string; + icon: IconName; + group: string; + className?: string; + draw: (container: HTMLElement) => void; +} + +/** Older names of settings pages, still accepted by open() */ +const SECTION_ALIASES: { [name: string]: string } = { request: "general" }; + // Default API Key header name const DEFAULT_API_KEY_HEADER = "X-API-Key"; @@ -38,9 +65,13 @@ export default class TabSettingsModal { private modalOverlay!: HTMLElement; private modalContent!: HTMLElement; private hamburgerContainer!: HTMLElement; - private prefixTextarea!: HTMLTextAreaElement; + private contentArea?: HTMLElement; + private currentPage = "general"; + private savedStatus!: HTMLElement; + private savedTimer?: ReturnType; + private prefixTextarea?: HTMLTextAreaElement; + private prefixTimer?: ReturnType; private aiSettingsForm?: AiSettingsForm; - private autoCaptureCheckbox!: HTMLInputElement; private hamburgerMenuButton!: HTMLButtonElement; private hamburgerDropdown!: HTMLElement; private isHamburgerOpen = false; @@ -215,6 +246,67 @@ export default class TabSettingsModal { this.hamburgerMenuButton.setAttribute("aria-expanded", "false"); } + /** The pages of the settings, in the order of the navigation */ + private pages(): SettingsPage[] { + const pages: SettingsPage[] = [ + { + id: "general", + label: "General", + icon: "alignLeft", + group: "Querying", + draw: (c) => this.drawGeneralSettings(c), + }, + { + id: "endpoints", + label: "Endpoints", + icon: "database", + group: "Querying", + draw: (c) => this.drawEndpointsSettings(c), + }, + { id: "prefix", label: "Prefixes", icon: "tag", group: "Querying", draw: (c) => this.drawPrefixSettings(c) }, + { id: "editor", label: "Editor", icon: "code", group: "Querying", draw: (c) => this.drawEditorSettings(c) }, + { + id: "workspaces", + label: "Workspaces", + icon: "folder", + group: "Your data", + draw: (c) => this.drawWorkspacesSettings(c), + }, + { + id: "importexport", + label: "Import / export", + icon: "fileDown", + group: "Your data", + draw: (c) => this.drawImportExportSettings(c), + }, + ]; + const assistant = this.tab.yasgui.aiAssistant; + if (assistant) { + pages.push({ + id: "ai", + label: "AI", + icon: "sparkles", + group: "Assistant", + className: "yasgui-aiSettings", + draw: (c) => { + this.aiSettingsForm = new AiSettingsForm(assistant, () => this.saved()); + this.aiSettingsForm.draw(c); + }, + }); + } + pages.push( + { + id: "shortcuts", + label: "Keyboard shortcuts", + icon: "keyboard", + group: "Help", + draw: (c) => this.drawKeyboardShortcuts(c), + }, + { id: "about", label: "About", icon: "help", group: "Help", draw: (c) => this.drawAboutSettings(c) }, + ); + return pages; + } + private createModal() { // Modal overlay this.modalOverlay = document.createElement("div"); @@ -238,209 +330,119 @@ export default class TabSettingsModal { // Modal content this.modalContent = document.createElement("div"); addClass(this.modalContent, "tabSettingsModal"); + this.modalContent.setAttribute("role", "dialog"); + this.modalContent.setAttribute("aria-modal", "true"); + this.modalContent.tabIndex = -1; this.modalContent.onclick = (e) => e.stopPropagation(); - // Header + // Header: the title and a close button; they stay in place while the page scrolls const header = document.createElement("div"); addClass(header, "modalHeader"); const title = document.createElement("h2"); title.textContent = "Settings"; + title.id = controlId("title"); + this.modalContent.setAttribute("aria-labelledby", title.id); header.appendChild(title); const closeBtn = document.createElement("button"); - closeBtn.textContent = "×"; + closeBtn.type = "button"; + closeBtn.innerHTML = icon("x", 18); + closeBtn.setAttribute("aria-label", "Close settings"); + closeBtn.title = "Close"; addClass(closeBtn, "closeButton"); closeBtn.onclick = () => this.close(); header.appendChild(closeBtn); this.modalContent.appendChild(header); - // Body with sidebar navigation + // Body: the pages, grouped in the navigation on the left const body = document.createElement("div"); addClass(body, "modalBody"); - // Sidebar navigation - const sidebar = document.createElement("div"); + const sidebar = document.createElement("nav"); addClass(sidebar, "modalSidebar"); + sidebar.setAttribute("aria-label", "Settings pages"); - const requestTab = document.createElement("button"); - requestTab.textContent = "Request"; - addClass(requestTab, "modalNavButton", "active"); - requestTab.onclick = () => this.switchTab("request"); - requestTab.dataset.tab = "request"; - - const endpointsTab = document.createElement("button"); - endpointsTab.textContent = "SPARQL Endpoints"; - addClass(endpointsTab, "modalNavButton"); - endpointsTab.onclick = () => this.switchTab("endpoints"); - endpointsTab.dataset.tab = "endpoints"; - - const workspacesTab = document.createElement("button"); - workspacesTab.textContent = "Workspaces"; - addClass(workspacesTab, "modalNavButton"); - workspacesTab.onclick = () => this.switchTab("workspaces"); - workspacesTab.dataset.tab = "workspaces"; - - const prefixTab = document.createElement("button"); - prefixTab.textContent = "Prefixes"; - addClass(prefixTab, "modalNavButton"); - prefixTab.onclick = () => this.switchTab("prefix"); - prefixTab.dataset.tab = "prefix"; - - const editorTab = document.createElement("button"); - editorTab.textContent = "Editor"; - addClass(editorTab, "modalNavButton"); - editorTab.onclick = () => this.switchTab("editor"); - editorTab.dataset.tab = "editor"; - - const importExportTab = document.createElement("button"); - importExportTab.textContent = "Import/Export"; - addClass(importExportTab, "modalNavButton"); - importExportTab.onclick = () => this.switchTab("importexport"); - importExportTab.dataset.tab = "importexport"; - - const shortcutsTab = document.createElement("button"); - shortcutsTab.textContent = "Keyboard Shortcuts"; - addClass(shortcutsTab, "modalNavButton"); - shortcutsTab.onclick = () => this.switchTab("shortcuts"); - shortcutsTab.dataset.tab = "shortcuts"; - - const aboutTab = document.createElement("button"); - aboutTab.textContent = "About"; - addClass(aboutTab, "modalNavButton"); - aboutTab.onclick = () => this.switchTab("about"); - aboutTab.dataset.tab = "about"; - - sidebar.appendChild(requestTab); - sidebar.appendChild(endpointsTab); - sidebar.appendChild(workspacesTab); - sidebar.appendChild(prefixTab); - sidebar.appendChild(editorTab); - const assistant = this.tab.yasgui.aiAssistant; - if (assistant) { - const aiTab = document.createElement("button"); - aiTab.textContent = "AI"; - addClass(aiTab, "modalNavButton"); - aiTab.onclick = () => this.switchTab("ai"); - aiTab.dataset.tab = "ai"; - sidebar.appendChild(aiTab); - } - sidebar.appendChild(importExportTab); - sidebar.appendChild(shortcutsTab); - sidebar.appendChild(aboutTab); - - // Content area container const contentArea = document.createElement("div"); addClass(contentArea, "modalContentArea"); + this.contentArea = contentArea; + + let group: string | undefined; + for (const page of this.pages()) { + if (page.group !== group) { + group = page.group; + const heading = document.createElement("div"); + addClass(heading, "modalNavGroup"); + heading.textContent = group; + sidebar.appendChild(heading); + } + const button = document.createElement("button"); + button.type = "button"; + addClass(button, "modalNavButton"); + button.dataset.tab = page.id; + button.innerHTML = icon(page.icon, 16) + ""; + (button.querySelector("span") as HTMLElement).textContent = page.label; + button.onclick = () => this.switchTab(page.id); + sidebar.appendChild(button); + + const content = document.createElement("div"); + addClass(content, "modalTabContent"); + if (page.className) addClass(content, page.className); + content.id = `${page.id}-content`; + page.draw(content); + contentArea.appendChild(content); + } body.appendChild(sidebar); body.appendChild(contentArea); - - // Tab content containers - const requestContent = document.createElement("div"); - addClass(requestContent, "modalTabContent", "active"); - requestContent.id = "request-content"; - this.drawRequestSettings(requestContent); - - const endpointsContent = document.createElement("div"); - addClass(endpointsContent, "modalTabContent"); - endpointsContent.id = "endpoints-content"; - this.drawEndpointsSettings(endpointsContent); - - const workspacesContent = document.createElement("div"); - addClass(workspacesContent, "modalTabContent"); - workspacesContent.id = "workspaces-content"; - this.drawWorkspacesSettings(workspacesContent); - - const prefixContent = document.createElement("div"); - addClass(prefixContent, "modalTabContent"); - prefixContent.id = "prefix-content"; - this.drawPrefixSettings(prefixContent); - - const editorContent = document.createElement("div"); - addClass(editorContent, "modalTabContent"); - editorContent.id = "editor-content"; - this.drawEditorSettings(editorContent); - - const importExportContent = document.createElement("div"); - addClass(importExportContent, "modalTabContent"); - importExportContent.id = "importexport-content"; - this.drawImportExportSettings(importExportContent); - - const shortcutsContent = document.createElement("div"); - addClass(shortcutsContent, "modalTabContent"); - shortcutsContent.id = "shortcuts-content"; - this.drawKeyboardShortcuts(shortcutsContent); - - const aboutContent = document.createElement("div"); - addClass(aboutContent, "modalTabContent"); - aboutContent.id = "about-content"; - this.drawAboutSettings(aboutContent); - - contentArea.appendChild(requestContent); - contentArea.appendChild(endpointsContent); - contentArea.appendChild(workspacesContent); - contentArea.appendChild(prefixContent); - contentArea.appendChild(editorContent); - if (assistant) { - const aiContent = document.createElement("div"); - addClass(aiContent, "modalTabContent", "yasgui-aiSettings"); - aiContent.id = "ai-content"; - this.aiSettingsForm = new AiSettingsForm(assistant); - this.aiSettingsForm.draw(aiContent); - contentArea.appendChild(aiContent); - } - contentArea.appendChild(importExportContent); - contentArea.appendChild(shortcutsContent); - contentArea.appendChild(aboutContent); - this.modalContent.appendChild(body); - // Footer + // Footer: changes apply at once, so it only says so and closes the settings const footer = document.createElement("div"); addClass(footer, "modalFooter"); - const saveBtn = document.createElement("button"); - saveBtn.textContent = "Save"; - addClass(saveBtn, "primaryButton"); - saveBtn.onclick = () => this.save(); - - const cancelBtn = document.createElement("button"); - cancelBtn.textContent = "Cancel"; - addClass(cancelBtn, "secondaryButton"); - cancelBtn.onclick = () => this.close(); - - footer.appendChild(cancelBtn); - footer.appendChild(saveBtn); + const note = document.createElement("p"); + addClass(note, "modalFooterNote"); + this.savedStatus = document.createElement("span"); + addClass(this.savedStatus, "modalSavedStatus"); + this.savedStatus.setAttribute("aria-live", "polite"); + note.appendChild(this.savedStatus); + note.appendChild(document.createTextNode("Changes apply at once. Settings stay in this browser.")); + footer.appendChild(note); + + const doneBtn = document.createElement("button"); + doneBtn.type = "button"; + doneBtn.textContent = "Done"; + addClass(doneBtn, "primaryButton"); + doneBtn.onclick = () => this.close(); + footer.appendChild(doneBtn); this.modalContent.appendChild(footer); this.modalOverlay.appendChild(this.modalContent); document.body.appendChild(this.modalOverlay); + this.switchTab("general"); } private switchTab(tabName: string) { + const name = SECTION_ALIASES[tabName] || tabName; const buttons = this.modalContent.querySelectorAll(".modalNavButton"); const contents = this.modalContent.querySelectorAll(".modalTabContent"); + if (!this.modalContent.querySelector(`#${name}-content`)) return; + this.currentPage = name; buttons.forEach((btn) => { - if ((btn as HTMLElement).dataset.tab === tabName) { - addClass(btn as HTMLElement, "active"); - } else { - removeClass(btn as HTMLElement, "active"); - } + const active = (btn as HTMLElement).dataset.tab === name; + (btn as HTMLElement).classList.toggle("active", active); + if (active) btn.setAttribute("aria-current", "page"); + else btn.removeAttribute("aria-current"); }); contents.forEach((content) => { - if (content.id === `${tabName}-content`) { + if (content.id === `${name}-content`) { addClass(content as HTMLElement, "active"); - // Reload editor settings if switching to editor tab and it hasn't been loaded yet - if (tabName === "editor" && content.innerHTML.indexOf("not yet initialized") > -1) { - content.innerHTML = ""; - this.drawEditorSettings(content as HTMLElement); - } // Refresh endpoints list when switching to endpoints tab - if (tabName === "endpoints") { + if (name === "endpoints") { const endpointsList = content.querySelector(".endpointsTable"); if (endpointsList) { this.renderEndpointsList(endpointsList as HTMLElement); @@ -450,6 +452,19 @@ export default class TabSettingsModal { removeClass(content as HTMLElement, "active"); } }); + if (this.contentArea) this.contentArea.scrollTop = 0; + } + + /** Says "Saved" in the footer for a moment, after a change was stored */ + private saved() { + if (!this.savedStatus) return; + this.savedStatus.textContent = "Saved"; + addClass(this.savedStatus, "visible"); + if (this.savedTimer) clearTimeout(this.savedTimer); + this.savedTimer = setTimeout(() => { + removeClass(this.savedStatus, "visible"); + this.savedStatus.textContent = ""; + }, 2500); } private drawWorkspacesSettings(container: HTMLElement) { @@ -469,251 +484,298 @@ export default class TabSettingsModal { } private drawPrefixSettings(container: HTMLElement) { - const section = document.createElement("div"); - addClass(section, "settingsSection"); - - const label = document.createElement("label"); - label.textContent = "Saved Prefixes"; - addClass(label, "settingsLabel"); - - const help = document.createElement("div"); - help.textContent = "Enter PREFIX declarations (one per line). These will be available for insertion into queries."; - addClass(help, "settingsHelp"); + container.innerHTML = ""; + const config = this.tab.yasgui.persistentConfig; + let prefixes = config.getPrefixes(); + // Set default prefixes if none exist + if (!prefixes || prefixes.trim().length === 0) { + prefixes = `PREFIX rdf: \nPREFIX rdfs: `; + config.setPrefixes(prefixes); + } + const group = document.createElement("section"); + addClass(group, "settingsGroup"); + const title = document.createElement("h3"); + addClass(title, "settingsGroupTitle"); + title.textContent = "Saved prefixes"; + const lede = document.createElement("p"); + addClass(lede, "settingsGroupLede"); + lede.textContent = + "PREFIX declarations, one per line. The Prefixes button of the query bar inserts them into the query."; this.prefixTextarea = document.createElement("textarea"); addClass(this.prefixTextarea, "prefixTextarea"); + this.prefixTextarea.setAttribute("aria-label", "Saved prefixes"); + this.prefixTextarea.spellcheck = false; this.prefixTextarea.placeholder = "PREFIX rdf: \nPREFIX rdfs: "; this.prefixTextarea.rows = 10; + this.prefixTextarea.value = prefixes; + this.prefixTextarea.addEventListener("input", () => { + if (this.prefixTimer) clearTimeout(this.prefixTimer); + this.prefixTimer = setTimeout(() => this.storePrefixes(), 500); + }); + this.prefixTextarea.addEventListener("change", () => this.storePrefixes()); + group.append(title, lede, this.prefixTextarea); + container.appendChild(group); + + const list = settingsGroup(container, "Capture"); + const id = controlId("autoCapture"); + settingsRow( + list, + "Capture new prefixes from queries", + "Prefixes declared in a query you run are added to this list.", + [ + settingsSwitch(id, config.getAutoCaptureEnabled(), (on) => { + config.setAutoCaptureEnabled(on); + this.saved(); + }), + ], + id, + ); + } - const checkboxContainer = document.createElement("div"); - addClass(checkboxContainer, "checkboxContainer"); - - this.autoCaptureCheckbox = document.createElement("input"); - this.autoCaptureCheckbox.type = "checkbox"; - this.autoCaptureCheckbox.id = "autoCapturePrefixes"; - - const checkboxLabel = document.createElement("label"); - checkboxLabel.htmlFor = "autoCapturePrefixes"; - checkboxLabel.textContent = "Automatically capture new prefixes from query editor"; - - checkboxContainer.appendChild(this.autoCaptureCheckbox); - checkboxContainer.appendChild(checkboxLabel); - - section.appendChild(label); - section.appendChild(help); - section.appendChild(this.prefixTextarea); - section.appendChild(checkboxContainer); - - container.appendChild(section); + /** Store the prefixes of the textarea, without duplicates */ + private storePrefixes() { + if (this.prefixTimer) clearTimeout(this.prefixTimer); + this.prefixTimer = undefined; + if (!this.prefixTextarea) return; + const deduplicated = deduplicatePrefixes(this.prefixTextarea.value); + if (deduplicated === this.tab.yasgui.persistentConfig.getPrefixes()) return; + this.tab.yasgui.persistentConfig.setPrefixes(deduplicated); + this.saved(); } - private drawEditorSettings(container: HTMLElement) { - const yasqe = this.tab.getYasqe(); - if (!yasqe) { - const notice = document.createElement("div"); - notice.textContent = "Query editor is not yet initialized."; - addClass(notice, "settingsHelp"); - container.appendChild(notice); - return; + /** Settings → General: appearance, requests and what happens before a query runs */ + private drawGeneralSettings(container: HTMLElement) { + container.innerHTML = ""; + const yasgui = this.tab.yasgui; + const config = yasgui.persistentConfig; + + // Appearance + const appearance = settingsGroup(container, "Appearance"); + if (yasgui.config.showThemeToggle !== false) { + settingsRow(appearance, "Theme", "Follows your system unless you pick one.", [ + settingsSegmented( + "Theme", + [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "system", label: "System" }, + ], + yasgui.themeManager.getPreference(), + (value) => { + if (value === "system") yasgui.themeManager.followSystem(); + else yasgui.setTheme(value as "light" | "dark"); + this.saved(); + }, + ), + ]); } + const lightId = controlId("editorThemeLight"); + const themeSelect = (mode: "light" | "dark", id: string, fallback: string) => + settingsSelect( + id, + EDITOR_THEMES.filter((theme) => theme.mode === mode).map(({ value, label }) => ({ value, label })), + config.getCodeMirrorTheme(mode) || fallback, + (value) => { + config.setCodeMirrorTheme(mode, value); + yasgui.themeManager.refresh(); + this.saved(); + }, + `Editor theme in ${mode} mode`, + ); + settingsRow( + appearance, + "Editor theme", + "Syntax colours in light mode and in dark mode.", + [themeSelect("light", lightId, "default"), themeSelect("dark", controlId("editorThemeDark"), "github-dark")], + lightId, + ); - // Available editor themes. Each theme is a `cm-s-` class applied to the - // CodeMirror 6 editor root; the styles for these ship with yasgui. - const themes = [ - { value: "default", label: "Default (Light)" }, - { value: "eclipse", label: "Eclipse (Light)" }, - { value: "idea", label: "IntelliJ IDEA (Light)" }, - { value: "solarized-light", label: "Solarized Light" }, - { value: "github-dark", label: "GitHub Dark Default" }, - { value: "monokai", label: "Monokai (Dark)" }, - { value: "dracula", label: "Dracula (Dark)" }, - { value: "material-darker", label: "Material Darker (Dark)" }, - ]; - - // Light Mode Theme Section - const themeLightSection = document.createElement("div"); - addClass(themeLightSection, "settingsSection"); - - const themeLightLabel = document.createElement("label"); - themeLightLabel.textContent = "Editor Theme (Light Mode)"; - addClass(themeLightLabel, "settingsLabel"); - - const themeLightHelp = document.createElement("div"); - themeLightHelp.textContent = "Syntax highlighting theme when using light mode."; - addClass(themeLightHelp, "settingsHelp"); - - const themeLightSelect = document.createElement("select"); - themeLightSelect.id = "codeMirrorThemeLightSelect"; - addClass(themeLightSelect, "settingsSelect"); - - themes.forEach((theme) => { - const option = document.createElement("option"); - option.value = theme.value; - option.textContent = theme.label; - themeLightSelect.appendChild(option); - }); - - const storedLightTheme = this.tab.yasgui.persistentConfig.getCodeMirrorTheme("light"); - themeLightSelect.value = storedLightTheme || "default"; - - themeLightSection.appendChild(themeLightLabel); - themeLightSection.appendChild(themeLightHelp); - themeLightSection.appendChild(themeLightSelect); - container.appendChild(themeLightSection); - - // Dark Mode Theme Section - const themeDarkSection = document.createElement("div"); - addClass(themeDarkSection, "settingsSection"); - - const themeDarkLabel = document.createElement("label"); - themeDarkLabel.textContent = "Editor Theme (Dark Mode)"; - addClass(themeDarkLabel, "settingsLabel"); - - const themeDarkHelp = document.createElement("div"); - themeDarkHelp.textContent = "Syntax highlighting theme when using dark mode."; - addClass(themeDarkHelp, "settingsHelp"); - - const themeDarkSelect = document.createElement("select"); - themeDarkSelect.id = "codeMirrorThemeDarkSelect"; - addClass(themeDarkSelect, "settingsSelect"); + // Requests, shared by all tabs + const requests = settingsGroup(container, "Requests", "These apply to every tab."); + const current = this.tab.getRequestConfig(); + const stored = config.getRequestSettings() || {}; + const method = stored.method || (typeof current.method === "string" ? current.method : "POST"); + settingsRow(requests, "Request method", "POST works with every endpoint and with long queries.", [ + settingsSegmented( + "Request method", + [ + { value: "POST", label: "POST" }, + { value: "GET", label: "GET" }, + ], + method, + (value) => this.applyRequestSettings({ method: value as "GET" | "POST" }), + ), + ]); + const selectId = controlId("acceptSelect"); + settingsRow( + requests, + "Results format", + "Accept header for SELECT and ASK queries.", + [ + settingsSelect( + selectId, + AcceptOptionsMap.map(({ key, value }) => ({ value, label: key })), + stored.acceptHeaderSelect || + (typeof current.acceptHeaderSelect === "string" ? current.acceptHeaderSelect : undefined), + (value) => this.applyRequestSettings({ acceptHeaderSelect: value }), + ), + ], + selectId, + ); + const graphId = controlId("acceptGraph"); + settingsRow( + requests, + "Graph format", + "Accept header for CONSTRUCT and DESCRIBE queries.", + [ + settingsSelect( + graphId, + AcceptHeaderGraphMap.map(({ key, value }) => ({ value, label: key })), + stored.acceptHeaderGraph || + (typeof current.acceptHeaderGraph === "string" ? current.acceptHeaderGraph : undefined), + (value) => this.applyRequestSettings({ acceptHeaderGraph: value }), + ), + ], + graphId, + ); + const headers = stored.headers || (typeof current.headers === "object" && current.headers ? current.headers : {}); + this.drawHeadersRow(requests, headers); - themes.forEach((theme) => { - const option = document.createElement("option"); - option.value = theme.value; - option.textContent = theme.label; - themeDarkSelect.appendChild(option); - }); + // Before running a query + const yasqe = this.tab.getYasqe(); + const run = settingsGroup(container, "Before running a query"); + const editorSettings = config.getEditorSettings(); + if (yasqe) { + const formatId = controlId("autoformat"); + settingsRow( + run, + "Format the query", + "With the formatter chosen under Editor.", + [ + settingsSwitch( + formatId, + editorSettings.autoformatOnQuery ?? yasqe.persistentConfig?.autoformatOnQuery !== false, + (on) => this.applyEditorSettings({ autoformatOnQuery: on }), + ), + ], + formatId, + ); + const constructId = controlId("constructCheck"); + settingsRow( + run, + "Check CONSTRUCT variables", + "Warn about template variables that the WHERE clause never binds.", + [ + settingsSwitch( + constructId, + editorSettings.checkConstructVariables ?? yasqe.config.checkConstructVariables ?? true, + (on) => this.applyEditorSettings({ checkConstructVariables: on }), + ), + ], + constructId, + ); + } + // Not shown when the embedding page switched the feature off + if (yasgui.config.iriHover !== false) { + const hoverId = controlId("iriHover"); + settingsRow( + run, + "IRI information on hover", + "Label, description and type of an IRI or prefixed name, from the endpoint.", + [ + settingsSwitch(hoverId, config.getIriHoverEnabled(), (on) => { + config.setIriHoverEnabled(on); + this.saved(); + }), + ], + hoverId, + ); + } + if (!run.children.length) run.parentElement?.remove(); + } - const storedDarkTheme = this.tab.yasgui.persistentConfig.getCodeMirrorTheme("dark"); - themeDarkSelect.value = storedDarkTheme || "github-dark"; - - themeDarkSection.appendChild(themeDarkLabel); - themeDarkSection.appendChild(themeDarkHelp); - themeDarkSection.appendChild(themeDarkSelect); - container.appendChild(themeDarkSection); + /** The custom HTTP headers: a summary in the row, and an editor that opens under it */ + private drawHeadersRow(list: HTMLElement, headers: { [name: string]: string }) { + const summary = (current: { [name: string]: string }) => { + const names = Object.keys(current); + if (!names.length) return "None. Sent with every query, e.g. an API key of the endpoint."; + return names.length === 1 ? `${names[0]}: ${current[names[0]]}` : `${names.join(", ")}`; + }; + const toggle = document.createElement("button"); + toggle.type = "button"; + addClass(toggle, "secondaryButton"); + toggle.textContent = "Edit headers"; + toggle.setAttribute("aria-expanded", "false"); + const row = settingsRow(list, "Custom HTTP headers", summary(headers), [toggle]); + const description = row.querySelector(".settingsRowDescription") as HTMLElement; + + const editor = document.createElement("div"); + addClass(editor, "keyValueContainer", "settingsRowEditor"); + editor.hidden = true; + const store = () => { + const next: { [name: string]: string } = {}; + editor.querySelectorAll(".keyValueRow").forEach((pair) => { + const name = (pair.querySelector(".keyInput") as HTMLInputElement).value.trim(); + const value = (pair.querySelector(".valueInput") as HTMLInputElement).value; + if (name) next[name] = value; + }); + description.textContent = summary(next); + this.applyRequestSettings({ headers: next }); + }; + const pairs = Object.entries(headers).map(([name, value]) => ({ name, value })); + pairs.forEach((pair, index) => this.createKeyValueRow(editor, pair, index, store)); + this.createKeyValueRow(editor, { name: "", value: "" }, pairs.length, store); + row.appendChild(editor); + toggle.onclick = () => { + editor.hidden = !editor.hidden; + toggle.textContent = editor.hidden ? "Edit headers" : "Close"; + toggle.setAttribute("aria-expanded", String(!editor.hidden)); + if (!editor.hidden) (editor.querySelector(".keyInput") as HTMLInputElement | null)?.focus(); + }; + } - // Formatter Type Section - const formatterSection = document.createElement("div"); - addClass(formatterSection, "settingsSection"); + /** Store request settings and apply them to every open tab; new tabs get them too */ + private applyRequestSettings(settings: RequestSettings) { + this.tab.yasgui.persistentConfig.setRequestSettings(settings); + for (const tab of this.tab.yasgui.getTabs()) tab.setRequestConfig(settings); + this.saved(); + } - const formatterLabel = document.createElement("label"); - formatterLabel.textContent = "Query Formatter"; - addClass(formatterLabel, "settingsLabel"); + /** Store editor settings and apply them to the editor of every open tab */ + private applyEditorSettings(settings: EditorSettings) { + this.tab.yasgui.persistentConfig.setEditorSettings(settings); + for (const tab of this.tab.yasgui.getTabs()) tab.applyEditorSettings(); + this.saved(); + } - const formatterHelp = document.createElement("div"); - formatterHelp.textContent = "Choose which formatter to use when formatting SPARQL queries (Shift+Ctrl+F)."; - addClass(formatterHelp, "settingsHelp"); - - const formatterSelect = document.createElement("select"); - formatterSelect.id = "formatterTypeSelect"; - addClass(formatterSelect, "settingsSelect"); - - const sparqlFormatterOption = document.createElement("option"); - sparqlFormatterOption.value = "sparql-formatter"; - sparqlFormatterOption.textContent = "SPARQL 1.2 Formatter"; - formatterSelect.appendChild(sparqlFormatterOption); - - const legacyOption = document.createElement("option"); - legacyOption.value = "legacy"; - legacyOption.textContent = "Legacy Formatter (built-in)"; - formatterSelect.appendChild(legacyOption); - - const currentFormatter = yasqe.persistentConfig?.formatterType || "sparql-formatter"; - formatterSelect.value = currentFormatter; - - formatterSection.appendChild(formatterLabel); - formatterSection.appendChild(formatterHelp); - formatterSection.appendChild(formatterSelect); - container.appendChild(formatterSection); - - // Auto-format on Query Section - const autoformatSection = document.createElement("div"); - addClass(autoformatSection, "settingsSection"); - - const autoformatCheckboxContainer = document.createElement("div"); - addClass(autoformatCheckboxContainer, "checkboxContainer"); - - const autoformatCheckbox = document.createElement("input"); - autoformatCheckbox.type = "checkbox"; - autoformatCheckbox.id = "autoformatOnQuery"; - autoformatCheckbox.checked = yasqe.persistentConfig?.autoformatOnQuery || true; - - const autoformatLabel = document.createElement("label"); - autoformatLabel.htmlFor = "autoformatOnQuery"; - autoformatLabel.textContent = "Auto-format query before execution"; - - const autoformatHelp = document.createElement("div"); - autoformatHelp.textContent = "Automatically format the query using the selected formatter before executing it."; - addClass(autoformatHelp, "settingsHelp"); - autoformatHelp.style.marginTop = "5px"; - - autoformatCheckboxContainer.appendChild(autoformatCheckbox); - autoformatCheckboxContainer.appendChild(autoformatLabel); - - autoformatSection.appendChild(autoformatCheckboxContainer); - autoformatSection.appendChild(autoformatHelp); - container.appendChild(autoformatSection); - - // CONSTRUCT Variable Validation Section - const constructValidationSection = document.createElement("div"); - addClass(constructValidationSection, "settingsSection"); - - const constructValidationCheckboxContainer = document.createElement("div"); - addClass(constructValidationCheckboxContainer, "checkboxContainer"); - - const constructValidationCheckbox = document.createElement("input"); - constructValidationCheckbox.type = "checkbox"; - constructValidationCheckbox.id = "checkConstructVariables"; - constructValidationCheckbox.checked = yasqe.config.checkConstructVariables ?? true; - - const constructValidationLabel = document.createElement("label"); - constructValidationLabel.htmlFor = "checkConstructVariables"; - constructValidationLabel.textContent = "Validate CONSTRUCT query variables"; - - const constructValidationHelp = document.createElement("div"); - constructValidationHelp.textContent = - "Show warnings for variables used in CONSTRUCT template but not defined in WHERE clause."; - addClass(constructValidationHelp, "settingsHelp"); - constructValidationHelp.style.marginTop = "5px"; - - constructValidationCheckboxContainer.appendChild(constructValidationCheckbox); - constructValidationCheckboxContainer.appendChild(constructValidationLabel); - - constructValidationSection.appendChild(constructValidationCheckboxContainer); - constructValidationSection.appendChild(constructValidationHelp); - container.appendChild(constructValidationSection); - - // IRI hover info Section (not shown when the embedding page switched the feature off) - if (this.tab.yasgui.config.iriHover !== false) { - const iriHoverSection = document.createElement("div"); - addClass(iriHoverSection, "settingsSection"); - - const iriHoverCheckboxContainer = document.createElement("div"); - addClass(iriHoverCheckboxContainer, "checkboxContainer"); - - const iriHoverCheckbox = document.createElement("input"); - iriHoverCheckbox.type = "checkbox"; - iriHoverCheckbox.id = "iriHoverEnabled"; - iriHoverCheckbox.checked = this.tab.yasgui.persistentConfig.getIriHoverEnabled(); - - const iriHoverLabel = document.createElement("label"); - iriHoverLabel.htmlFor = "iriHoverEnabled"; - iriHoverLabel.textContent = "Show IRI information on hover"; - - const iriHoverHelp = document.createElement("div"); - iriHoverHelp.textContent = - "When hovering an IRI or prefixed name, show its label, description and type, fetched from the endpoint."; - addClass(iriHoverHelp, "settingsHelp"); - iriHoverHelp.style.marginTop = "5px"; - - iriHoverCheckboxContainer.appendChild(iriHoverCheckbox); - iriHoverCheckboxContainer.appendChild(iriHoverLabel); - - iriHoverSection.appendChild(iriHoverCheckboxContainer); - iriHoverSection.appendChild(iriHoverHelp); - container.appendChild(iriHoverSection); - } + private drawEditorSettings(container: HTMLElement) { + container.innerHTML = ""; + const yasqe = this.tab.getYasqe(); + const list = settingsGroup(container, "Formatting"); + const id = controlId("formatter"); + const current = + this.tab.yasgui.persistentConfig.getEditorSettings().formatterType || + yasqe?.persistentConfig?.formatterType || + "sparql-formatter"; + settingsRow( + list, + "Query formatter", + "Used by Format (Shift+Ctrl+F) and before running a query.", + [ + settingsSelect( + id, + [ + { value: "sparql-formatter", label: "SPARQL 1.2 formatter" }, + { value: "legacy", label: "Legacy formatter (built-in)" }, + ], + current, + (value) => this.applyEditorSettings({ formatterType: value as "sparql-formatter" | "legacy" }), + ), + ], + id, + ); } private drawEndpointsSettings(container: HTMLElement) { @@ -1495,78 +1557,29 @@ export default class TabSettingsModal { document.body.appendChild(authModalOverlay); } - private drawRequestSettings(container: HTMLElement) { - // This is a simplified version - you can expand based on TabPanel.ts - const reqConfig = this.tab.getRequestConfig(); - - // Request Method section - const methodSection = this.createSection("Request Method"); - const methodSelect = this.createSelect( - ["GET", "POST"], - typeof reqConfig.method === "function" ? "POST" : reqConfig.method, - ); - methodSelect.setAttribute("data-config", "method"); - methodSection.appendChild(methodSelect); - container.appendChild(methodSection); - - // Accept Header sections - const acceptSection = this.createSection("Accept Header (SELECT/ASK)"); - const acceptSelect = this.createOptionsSelect(AcceptOptionsMap, reqConfig.acceptHeaderSelect); - acceptSelect.setAttribute("data-config", "acceptHeaderSelect"); - acceptSection.appendChild(acceptSelect); - container.appendChild(acceptSection); - - const acceptGraphSection = this.createSection("Accept Header (CONSTRUCT/DESCRIBE)"); - const acceptGraphSelect = this.createOptionsSelect(AcceptHeaderGraphMap, reqConfig.acceptHeaderGraph); - acceptGraphSelect.setAttribute("data-config", "acceptHeaderGraph"); - acceptGraphSection.appendChild(acceptGraphSelect); - container.appendChild(acceptGraphSection); - - // Custom Headers section - const headersSection = this.createSection("Custom HTTP Headers"); - const headersContainer = document.createElement("div"); - addClass(headersContainer, "keyValueContainer"); - headersContainer.setAttribute("data-config", "headers"); - - // Get existing headers - const existingHeaders = typeof reqConfig.headers === "function" ? {} : reqConfig.headers || {}; - const headerPairs: KeyValuePair[] = Object.entries(existingHeaders).map(([name, value]) => ({ - name, - value, - })); - - // Draw existing headers - headerPairs.forEach((pair, index) => { - this.createKeyValueRow(headersContainer, pair, index); - }); - - // Add empty row for adding new headers - this.createKeyValueRow(headersContainer, { name: "", value: "" }, headerPairs.length); - - headersSection.appendChild(headersContainer); - container.appendChild(headersSection); - } - - private createKeyValueRow(container: HTMLElement, pair: KeyValuePair, index: number) { + private createKeyValueRow(container: HTMLElement, pair: KeyValuePair, index: number, onChange: () => void) { const row = document.createElement("div"); addClass(row, "keyValueRow"); const nameInput = document.createElement("input"); nameInput.type = "text"; - nameInput.placeholder = "Header Name"; + nameInput.placeholder = "Header name"; + nameInput.setAttribute("aria-label", "Header name"); nameInput.value = pair.name; addClass(nameInput, "keyInput"); const valueInput = document.createElement("input"); valueInput.type = "text"; - valueInput.placeholder = "Header Value"; + valueInput.placeholder = "Value"; + valueInput.setAttribute("aria-label", "Header value"); valueInput.value = pair.value; addClass(valueInput, "valueInput"); const removeButton = document.createElement("button"); removeButton.type = "button"; - removeButton.innerHTML = ''; + removeButton.innerHTML = icon("x", 14); removeButton.title = "Remove header"; + removeButton.setAttribute("aria-label", "Remove header"); addClass(removeButton, "removeButton"); // Auto-add new empty row when typing in the last row @@ -1575,21 +1588,23 @@ export default class TabSettingsModal { const isLastRow = row === allRows[allRows.length - 1]; if (isLastRow && (nameInput.value.trim() || valueInput.value.trim())) { - // Add a new empty row - this.createKeyValueRow(container, { name: "", value: "" }, allRows.length); + this.createKeyValueRow(container, { name: "", value: "" }, allRows.length, onChange); } }; nameInput.addEventListener("input", onInput); valueInput.addEventListener("input", onInput); + nameInput.addEventListener("change", onChange); + valueInput.addEventListener("change", onChange); removeButton.onclick = () => { row.remove(); // Ensure there's always at least one empty row const remainingRows = container.querySelectorAll(".keyValueRow"); if (remainingRows.length === 0) { - this.createKeyValueRow(container, { name: "", value: "" }, 0); + this.createKeyValueRow(container, { name: "", value: "" }, 0, onChange); } + onChange(); }; row.appendChild(nameInput); @@ -1598,54 +1613,23 @@ export default class TabSettingsModal { container.appendChild(row); } - private createSection(title: string): HTMLElement { - const section = document.createElement("div"); - addClass(section, "settingsSection"); - const label = document.createElement("div"); - addClass(label, "settingsLabel"); - label.textContent = title; - section.appendChild(label); - return section; - } - - private createSelect(options: string[], selected?: string): HTMLSelectElement { - const select = document.createElement("select"); - addClass(select, "settingsSelect"); - options.forEach((opt) => { - const option = document.createElement("option"); - option.value = opt; - option.textContent = opt; - if (opt === selected) option.selected = true; - select.appendChild(option); - }); - return select; - } - - private createOptionsSelect(options: { key: string; value: string }[], selected?: string): HTMLSelectElement { - const select = document.createElement("select"); - addClass(select, "settingsSelect"); - options.forEach((opt) => { - const option = document.createElement("option"); - option.value = opt.value; - option.textContent = opt.key; - if (opt.value === selected) option.selected = true; - select.appendChild(option); - }); - return select; - } - /** * Open the settings modal, optionally on a section: - * "request", "endpoints", "workspaces", "prefix", "editor", "ai", "importexport", "shortcuts" or "about" + * "general" (or "request"), "endpoints", "workspaces", "prefix", "editor", "ai", "importexport", "shortcuts" + * or "about" */ public open(section?: string) { - if (section) this.switchTab(section); - this.loadSettings(); - // Reload editor settings in case yasqe wasn't available during init - const editorContent = this.modalContent.querySelector("#editor-content"); - if (editorContent && editorContent.innerHTML === "") { - this.drawEditorSettings(editorContent as HTMLElement); + // Settings may have changed in another tab, so the pages that show them are drawn again + for (const [id, draw] of [ + ["general", (c: HTMLElement) => this.drawGeneralSettings(c)], + ["prefix", (c: HTMLElement) => this.drawPrefixSettings(c)], + ["editor", (c: HTMLElement) => this.drawEditorSettings(c)], + ] as Array<[string, (c: HTMLElement) => void]>) { + const content = this.modalContent.querySelector(`#${id}-content`); + if (content) draw(content as HTMLElement); } + this.aiSettingsForm?.load(); + this.switchTab(section || this.currentPage); // Refresh endpoints list in case new endpoints were added while modal was closed const endpointsContent = this.modalContent.querySelector("#endpoints-content"); if (endpointsContent) { @@ -1654,132 +1638,23 @@ export default class TabSettingsModal { this.renderEndpointsList(endpointsList as HTMLElement); } } + if (this.savedStatus) { + removeClass(this.savedStatus, "visible"); + this.savedStatus.textContent = ""; + } addClass(this.modalOverlay, "open"); document.addEventListener("keydown", this.handleKeyDown); + this.modalContent.focus(); } public close() { + if (!this.modalOverlay.classList.contains("open")) return; + // Changes apply at once; prefixes still being typed are stored now + this.storePrefixes(); this.mouseDownOnOverlay = false; removeClass(this.modalOverlay, "open"); document.removeEventListener("keydown", this.handleKeyDown); - } - - private loadSettings() { - // Load prefix settings - let prefixes = this.tab.yasgui.persistentConfig.getPrefixes(); - const autoCapture = this.tab.yasgui.persistentConfig.getAutoCaptureEnabled(); - - // Set default prefixes if none exist - if (!prefixes || prefixes.trim().length === 0) { - prefixes = `PREFIX rdf: \nPREFIX rdfs: `; - this.tab.yasgui.persistentConfig.setPrefixes(prefixes); - } - - this.prefixTextarea.value = prefixes; - this.autoCaptureCheckbox.checked = autoCapture; - this.aiSettingsForm?.load(); - } - - private save() { - // Save prefix settings - const prefixText = this.prefixTextarea.value; - const autoCapture = this.autoCaptureCheckbox.checked; - - // Parse and deduplicate prefixes - const deduplicated = deduplicatePrefixes(prefixText); - this.tab.yasgui.persistentConfig.setPrefixes(deduplicated); - this.tab.yasgui.persistentConfig.setAutoCaptureEnabled(autoCapture); - this.aiSettingsForm?.save(); - - // Save editor settings - const yasqe = this.tab.getYasqe(); - if (yasqe && yasqe.persistentConfig) { - const codeMirrorThemeLightSelect = document.getElementById("codeMirrorThemeLightSelect") as HTMLSelectElement; - const codeMirrorThemeDarkSelect = document.getElementById("codeMirrorThemeDarkSelect") as HTMLSelectElement; - const formatterSelect = document.getElementById("formatterTypeSelect") as HTMLSelectElement; - const autoformatCheckbox = document.getElementById("autoformatOnQuery") as HTMLInputElement; - const constructValidationCheckbox = document.getElementById("checkConstructVariables") as HTMLInputElement; - const iriHoverCheckbox = document.getElementById("iriHoverEnabled") as HTMLInputElement | null; - - // Save CodeMirror themes for both light and dark modes - if (codeMirrorThemeLightSelect) { - const selectedTheme = codeMirrorThemeLightSelect.value; - this.tab.yasgui.persistentConfig.setCodeMirrorTheme("light", selectedTheme); - } - - if (codeMirrorThemeDarkSelect) { - const selectedTheme = codeMirrorThemeDarkSelect.value; - this.tab.yasgui.persistentConfig.setCodeMirrorTheme("dark", selectedTheme); - } - - // Apply the appropriate theme based on current mode - const currentMode = this.tab.yasgui.getTheme(); - const themeToApply = - currentMode === "dark" - ? codeMirrorThemeDarkSelect?.value || "github-dark" - : codeMirrorThemeLightSelect?.value || "default"; - - // Apply theme to all CodeMirror instances - const cmElements = document.querySelectorAll(".CodeMirror"); - cmElements.forEach((element) => { - const cm = (element as any).CodeMirror; - if (cm && cm.setOption) { - cm.setOption("theme", themeToApply); - } - }); - - if (formatterSelect) { - yasqe.persistentConfig.formatterType = formatterSelect.value as "sparql-formatter" | "legacy"; - } - if (autoformatCheckbox) { - yasqe.persistentConfig.autoformatOnQuery = autoformatCheckbox.checked; - } - if (constructValidationCheckbox) { - yasqe.setCheckConstructVariables(constructValidationCheckbox.checked); - } - if (iriHoverCheckbox) { - this.tab.yasgui.persistentConfig.setIriHoverEnabled(iriHoverCheckbox.checked); - } - yasqe.saveQuery(); - } - - // Save request settings - const requestContent = this.modalContent.querySelector("#request-content"); - if (requestContent) { - const selects = requestContent.querySelectorAll("select[data-config]"); - const updates: any = {}; - selects.forEach((select) => { - const config = (select as HTMLSelectElement).getAttribute("data-config"); - if (config) { - updates[config] = (select as HTMLSelectElement).value; - } - }); - - // Save custom headers - const headersContainer = requestContent.querySelector(".keyValueContainer[data-config='headers']"); - if (headersContainer) { - const headers: { [key: string]: string } = {}; - const rows = headersContainer.querySelectorAll(".keyValueRow"); - rows.forEach((row) => { - const nameInput = row.querySelector(".keyInput") as HTMLInputElement; - const valueInput = row.querySelector(".valueInput") as HTMLInputElement; - if (nameInput && valueInput && nameInput.value.trim()) { - headers[nameInput.value.trim()] = valueInput.value; - } - }); - updates.headers = headers; - } - - this.tab.setRequestConfig(updates); - } - - // Note: Authentication is now handled per-endpoint in the Endpoints tab, - // not per-tab anymore. No need to save it here. - - // Refresh endpoint buttons to show any changes this.tab.refreshEndpointButtons(); - - this.close(); } /** Insert the saved prefixes (Settings > Prefixes) at the top of the query */ diff --git a/packages/yasgui/src/ThemeManager.ts b/packages/yasgui/src/ThemeManager.ts index e0d9721b..e8d2d405 100644 --- a/packages/yasgui/src/ThemeManager.ts +++ b/packages/yasgui/src/ThemeManager.ts @@ -43,6 +43,18 @@ export class ThemeManager { }); } + /** "light" or "dark" when the user picked one, "system" when the theme follows the operating system */ + public getPreference(): Theme | "system" { + return this.getStoredTheme() || "system"; + } + + /** Forget the theme the user picked and follow the operating system again */ + public followSystem(): void { + this.storage.remove(ThemeManager.STORAGE_KEY); + this.currentTheme = this.getSystemTheme(); + this.applyTheme(this.currentTheme); + } + /** * Toggle between light and dark themes */ diff --git a/packages/yasgui/src/ai/AiPanel.scss b/packages/yasgui/src/ai/AiPanel.scss index 6d6d2709..38c47331 100644 --- a/packages/yasgui/src/ai/AiPanel.scss +++ b/packages/yasgui/src/ai/AiPanel.scss @@ -465,95 +465,622 @@ } } -// Settings. The colour is set here because the form's labels and choices have no class of the settings modal. +// Settings → AI (AiSettingsForm.ts). The colour is set here because the page sits outside .yasgui. .yasgui-aiSettings { - color: var(--yasgui-text-primary, #000); + color: var(--yasgui-ink, #1c2633); - &__choices { - display: flex; - flex-direction: column; + &__cards { + display: grid; gap: 8px; + margin-top: 10px; } - &__choice { + // A service: a radio for the default, a logo, the name with badges and a line about it, and its actions + &__card { display: flex; align-items: center; - gap: 10px; - padding: 10px 12px; - border: 1px solid var(--yasgui-border-color, #dfe5ec); - border-radius: var(--yasgui-radius-md, 10px); - background: var(--yasgui-bg-secondary, #fff); - cursor: pointer; + gap: 12px; + padding: 0 14px 0 0; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: 12px; + background: var(--yasgui-surface-raised, #fff); - &:has(input:checked) { - border-color: var(--yasgui-ai-accent); + &.default { + border-color: color-mix(in srgb, var(--yasgui-ai-accent) 45%, transparent); background: var(--yasgui-ai-accent-soft); } - input { - margin: 0; + &:has(input:focus-visible) { + outline: 2px solid var(--yasgui-focus, #044a8c); + outline-offset: 2px; } + } - strong { - display: block; - font-weight: 600; - } + &__cardMain { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 11px 0 11px 14px; + cursor: pointer; } - &__choiceText { + &__radio { + flex: none; + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--yasgui-ai-accent); + cursor: pointer; + } + + &__logo { + flex: none; + width: 30px; + height: 30px; + border-radius: 8px; + display: grid; + place-items: center; + color: #fff; + font-weight: 650; + font-size: 12px; + } + + &__cardText { flex: 1; min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; } - &__choiceActions { + &__cardTitle { display: flex; + flex-wrap: wrap; + align-items: center; gap: 6px; + + strong { + font-weight: 600; + } + } + + &__cardMeta { + font-size: 12.5px; + color: var(--yasgui-ink-subtle, #6a7380); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__cardActions { + flex: none; + display: flex; + gap: 12px; + } + + &__linkButton { + border: 0; + background: none; + padding: 2px 0; + font-size: 12.5px; + font-weight: 500; + color: var(--yasgui-ink-muted, #56606c); + cursor: pointer; + + &:hover { + color: var(--yasgui-ink, #1c2633); + text-decoration: underline; + } + + &.danger { + color: var(--yasgui-danger, #b42318); + } + + &[hidden] { + display: none; + } + } + + &__forget { + justify-self: start; } &__add { - margin-top: 10px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px; + border: 1px dashed var(--yasgui-line-grip, #c9d2dc); + border-radius: 12px; + background: none; + color: var(--yasgui-ink-muted, #56606c); + font-size: 13px; + font-weight: 500; + cursor: pointer; + + &:hover { + color: var(--yasgui-ink, #1c2633); + border-color: var(--yasgui-line-strong, #808893); + } } - &__choiceHelp { - display: block; - font-size: 12px; - color: var(--yasgui-text-secondary, #666); + // The form of one service + &__crumb { + display: flex; + align-items: center; + gap: 6px; + margin: 14px 0 10px; + font-size: 12.5px; + color: var(--yasgui-ink-subtle, #6a7380); } - &__own[hidden] { - display: none; + &__back { + border: 0; + background: none; + padding: 0; + font-size: 12.5px; + font-weight: 500; + color: var(--yasgui-brand, #0563bb); + cursor: pointer; + + &:hover { + text-decoration: underline; + } } - &__field { - margin: 10px 0; + &__form { + display: grid; + gap: 14px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--yasgui-ai-accent) 40%, transparent); + border-radius: 12px; + background: var(--yasgui-surface-raised, #fff); + } - label { - display: block; + &__formTitle { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + + strong { font-weight: 600; - margin-bottom: 4px; + } + + span { + font-size: 12.5px; + color: var(--yasgui-ink-subtle, #6a7380); + } + } + + &__pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } + } + + &__field { + display: grid; + gap: 5px; + min-width: 0; + } + + &__fieldLabel { + display: flex; + align-items: center; + gap: 6px; + min-height: 20px; + font-size: 12.5px; + font-weight: 500; + color: var(--yasgui-ink-muted, #56606c); + + > :nth-child(2):not(.yasgui-pill), + > .yasgui-aiSettings__linkButton, + > .yasgui-aiSettings__keyLink { + margin-left: auto; + } + } + + &__aside { + font-weight: 400; + color: var(--yasgui-ink-subtle, #6a7380); + } + + &__keyLink { + color: var(--yasgui-brand, #0563bb); + font-weight: 500; + text-decoration: none; + + &:hover { + text-decoration: underline; } } + &__hint { + font-size: 12px; + color: var(--yasgui-ink-subtle, #6a7380); + } + &__input { box-sizing: border-box; width: 100%; - padding: 6px 8px; - border: 1px solid var(--yasgui-input-border, #808893); - border-radius: var(--yasgui-radius-sm, 6px); + min-height: 34px; + padding: 6px 10px; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: var(--yasgui-radius-button, 8px); background: var(--yasgui-surface-raised, #fff); color: var(--yasgui-ink, #1c2633); font: inherit; + + &:focus { + outline: none; + border-color: var(--yasgui-brand, #0563bb); + box-shadow: 0 0 0 3px var(--yasgui-brand-soft, #e3eefa); + } + } + + &__baseUrl, + &__keyBox &__input { + font-family: var(--yasgui-font-mono, monospace); + font-size: 12.5px; + } + + &__keyBox { + position: relative; + + .yasgui-aiSettings__input { + padding-right: 60px; + } + } + + &__eye { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + border: 0; + background: none; + padding: 4px 6px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: var(--yasgui-ink-muted, #56606c); + cursor: pointer; + + &:hover { + background: var(--yasgui-surface-sunken, #edf1f6); + } + } + + &__modelRow { + display: flex; + gap: 8px; + + .yasgui-combo { + flex: 1; + min-width: 0; + } + } + + &__status { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--yasgui-ink-muted, #56606c); + + &:empty { + display: none; + } + + &.ok, + &.warn, + &.error { + &::before { + content: ""; + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + } + } + + &.ok { + color: var(--yasgui-success, #067647); + } + + &.warn { + color: var(--yasgui-highlight-ink, #6e4d00); + } + + &.error { + color: var(--yasgui-danger, #b42318); + } } - &__row { + &__notice { + padding: 10px 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--yasgui-highlight, #f2b300) 14%, transparent); + font-size: 12.5px; + line-height: 1.5; + + &[hidden] { + display: none; + } + } + + &__formFooter { display: flex; + align-items: center; + justify-content: flex-end; flex-wrap: wrap; + gap: 8px; + padding-top: 2px; + } + + &__defaultSwitch { + display: flex; align-items: center; gap: 8px; - margin: 6px 0; + margin-right: auto; + font-size: 12.5px; + color: var(--yasgui-ink-muted, #56606c); + cursor: pointer; + } +} + +[data-theme="dark"] .yasgui-aiSettings { + &__card:not(.default), + &__form { + background: var(--yasgui-surface-sunken, #1c222b); + } + + &__input { + background: var(--yasgui-surface-raised, #141920); + } +} + +// Badges and pills of the settings +.yasgui-badge { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 16px; + + &--default { + color: var(--yasgui-ai-accent); + background: var(--yasgui-ai-accent-soft); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--yasgui-ai-accent) 40%, transparent); + } + + &--site { + color: var(--yasgui-brand, #0563bb); + background: var(--yasgui-brand-soft, #e3eefa); } - &__testResult.ok { + &--region { color: var(--yasgui-success, #067647); + background: color-mix(in srgb, var(--yasgui-success, #067647) 12%, transparent); + } +} + +.yasgui-pill { + padding: 0 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 17px; + color: var(--yasgui-ink-subtle, #6a7380); + background: var(--yasgui-surface-sunken, #edf1f6); + box-shadow: inset 0 0 0 1px var(--yasgui-line, #dfe5ec); + + &--changed { + color: var(--yasgui-highlight-ink, #6e4d00); + background: color-mix(in srgb, var(--yasgui-highlight, #f2b300) 18%, transparent); + box-shadow: none; + } + + &[hidden] { + display: none; + } +} + +// The dropdown with a search field (Combobox.ts) +.yasgui-combo { + position: relative; + + &__button { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 34px; + padding: 5px 10px; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: var(--yasgui-radius-button, 8px); + background: var(--yasgui-surface-raised, #fff); + color: var(--yasgui-ink, #1c2633); + font: inherit; + text-align: left; + cursor: pointer; + + &:focus-visible, + .open > & { + outline: none; + border-color: var(--yasgui-brand, #0563bb); + box-shadow: 0 0 0 3px var(--yasgui-brand-soft, #e3eefa); + } + } + + &--mono &__value, + &--mono &__label { + font-family: var(--yasgui-font-mono, monospace); + font-size: 12.5px; + } + + &__value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &.placeholder { + color: var(--yasgui-ink-subtle, #6a7380); + font-family: inherit !important; + } + } + + &__chevron { + display: flex; + flex: none; + color: var(--yasgui-ink-subtle, #6a7380); + } + + &__logo { + flex: none; + width: 20px; + height: 20px; + border-radius: 5px; + display: grid; + place-items: center; + color: #fff; + font-size: 10px; + font-weight: 650; + } + + &__pop { + position: fixed; + z-index: 10001; + display: flex; + flex-direction: column; + box-sizing: border-box; + overflow: hidden; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: 10px; + background: var(--yasgui-surface-raised, #fff); + box-shadow: var(--yasgui-shadow-overlay, 0 8px 24px rgba(0, 0, 0, 0.2)); + + &[hidden] { + display: none; + } + } + + &__search { + flex: none; + margin: 8px; + padding: 6px 10px; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: var(--yasgui-radius-button, 8px); + background: var(--yasgui-surface, #f1f4f8); + color: var(--yasgui-ink, #1c2633); + font: inherit; + + &:focus { + outline: none; + border-color: var(--yasgui-brand, #0563bb); + } + } + + &__list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 6px; + border-top: 1px solid var(--yasgui-line-soft, #edf0f4); + } + + &__group { + padding: 8px 12px 3px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--yasgui-ink-subtle, #6a7380); + } + + &__option { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + cursor: pointer; + + &.highlighted { + background: var(--yasgui-brand-soft, #e3eefa); + } + } + + &__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + } + + &__star { + color: var(--yasgui-highlight, #f2b300); + } + + &__sub { + margin-left: auto; + flex: none; + font-size: 12px; + color: var(--yasgui-ink-subtle, #6a7380); + } + + &__button &__sub { + margin-left: 0; + } + + &__check { + display: flex; + margin-left: auto; + color: var(--yasgui-brand, #0563bb); + } + + &__sub + &__check { + margin-left: 0; + } + + &__empty { + padding: 10px 12px; + color: var(--yasgui-ink-subtle, #6a7380); + } + + &__footer { + flex: none; + padding: 7px 12px; + font-size: 12px; + color: var(--yasgui-ink-subtle, #6a7380); + border-top: 1px solid var(--yasgui-line-soft, #edf0f4); + + &[hidden] { + display: none; + } + } +} + +[data-theme="dark"] .yasgui-combo { + &__button { + background: var(--yasgui-surface-raised, #141920); + } + + &__pop { + background: #1a2028; } } diff --git a/packages/yasgui/src/ai/AiSettingsForm.ts b/packages/yasgui/src/ai/AiSettingsForm.ts index 2d439ed5..c47656af 100644 --- a/packages/yasgui/src/ai/AiSettingsForm.ts +++ b/packages/yasgui/src/ai/AiSettingsForm.ts @@ -1,14 +1,27 @@ /** - * "AI" section of the settings: the user's own AI services (base URL, API key, model), the services of the site, - * and which one is the default. + * "AI" page of the settings: the AI services as cards (the site's, the user's own, and "No AI service") with the + * default one picked, and a form to add or edit one of the user's own services: a provider, its base URL (filled in, + * still editable), the API key and a model fetched from the service. Changes apply at once; the form has its own + * Save, because its fields only make sense together. */ -import { addClass } from "@matdata/yasgui-utils"; import type AiAssistant from "./AiAssistant"; +import Combobox from "./Combobox"; +import type { ComboOption } from "./Combobox"; import { hostOf, isLocalUrl, newServiceId, normalizeBaseUrl, ownConnection, siteServices } from "./aiSettings"; -import { listModels } from "./openaiClient"; -import { aiProviders, providerOf } from "./providers"; +import { AiRequestError, listModels } from "./openaiClient"; +import { + aiProviders, + CUSTOM_PROVIDER_ID, + isChatModel, + providerById, + providerGroupLabels, + providerOf, +} from "./providers"; +import type { AiProvider } from "./providers"; import { AI_SERVICE_OFF } from "./types"; import type { AiServiceSettings } from "./types"; +import { icon } from "../icons"; +import { controlId, settingsGroup, settingsRow, settingsSwitch } from "../settingsUi"; function el(tag: K, className?: string, text?: string) { const element = document.createElement(tag); @@ -17,412 +30,588 @@ function el(tag: K, className?: string, t return element; } -export default class AiSettingsForm { - private servicesEl!: HTMLDivElement; - private editorTitle!: HTMLLabelElement; - private nameInput!: HTMLInputElement; - private baseUrlInput!: HTMLInputElement; - private apiKeyInput!: HTMLInputElement; - private modelInput!: HTMLInputElement; - private modelList!: HTMLDataListElement; - private checkQueriesInput?: HTMLInputElement; - private ownFields!: HTMLDivElement; - private testResult!: HTMLDivElement; - private consentEl!: HTMLDivElement; - /** The user's own services as edited in the form; stored on save */ - private services: AiServiceSettings[] = []; - private defaultId: string = AI_SERVICE_OFF; - private editingId: string | undefined; - private formId = Math.random().toString(36).substring(7); - - constructor(private assistant: AiAssistant) {} +/** The service being added or edited, before it is saved */ +interface Draft extends AiServiceSettings { + isNew: boolean; + makeDefault: boolean; +} - public draw(container: HTMLElement) { - const config = this.assistant.config; - const id = this.formId; +/** Models found for a base URL: from the service, or the provider's list when the browser can't read it */ +interface FoundModels { + ids: string[]; + fromService: boolean; + hidden: number; +} - const intro = el("div", "settingsSection"); - intro.appendChild(el("label", "settingsLabel", "AI assistant")); - intro.appendChild( - el( - "div", - "settingsHelp", - "The AI assistant writes queries from questions in your own words, explains and fixes queries, and explains results. It is in the AI tab of the side panel. Without an AI service, its actions copy a ready-made prompt that you paste in an AI chat of your choice.", - ), - ); - container.appendChild(intro); +export default class AiSettingsForm { + private container!: HTMLElement; + private draft: Draft | undefined; + /** Models fetched in this session, by base URL, so reopening a service doesn't fetch them again */ + private found = new Map(); - // Services - const servicesSection = el("div", "settingsSection"); - servicesSection.appendChild(el("label", "settingsLabel", "AI services")); - servicesSection.appendChild( - el( - "div", - "settingsHelp", - config.allowOwnKey - ? "Add the AI services you want to use and pick the default one. You can switch between them in the AI view." - : "Pick the default AI service. You can switch between them in the AI view.", - ), - ); - this.servicesEl = el("div", "yasgui-aiSettings__choices"); - this.servicesEl.setAttribute("role", "radiogroup"); - this.servicesEl.setAttribute("aria-label", "Default AI service"); - servicesSection.appendChild(this.servicesEl); - if (config.allowOwnKey) { - const add = el("button", "secondaryButton yasgui-aiSettings__add", "Add service"); - add.type = "button"; - add.addEventListener("click", () => this.addService()); - servicesSection.appendChild(add); - } - container.appendChild(servicesSection); + constructor( + private assistant: AiAssistant, + /** Called after a change was stored */ + private onSaved: () => void = () => {}, + ) {} - // Editor of one of the user's own services - this.ownFields = el("div", "settingsSection yasgui-aiSettings__own"); - this.editorTitle = el("label", "settingsLabel", "Service"); - this.ownFields.appendChild(this.editorTitle); - this.ownFields.appendChild( - el( - "div", - "settingsHelp", - "Any service with an OpenAI-compatible API works, or a proxy that offers one. Pick a provider from the Base URL list or enter another URL.", - ), - ); - const field = (labelText: string, input: HTMLInputElement, help?: string) => { - const wrapper = el("div", "yasgui-aiSettings__field"); - const label = el("label", undefined, labelText); - input.id = `ai-${labelText.replace(/\W+/g, "")}-${id}`; - label.htmlFor = input.id; - wrapper.append(label, input); - if (help) wrapper.appendChild(el("div", "settingsHelp", help)); - this.ownFields.appendChild(wrapper); - return wrapper; - }; + public draw(container: HTMLElement) { + this.container = container; + this.load(); + } - this.baseUrlInput = el("input", "yasgui-aiSettings__input"); - this.baseUrlInput.type = "url"; - this.baseUrlInput.placeholder = config.defaultBaseUrl; - this.baseUrlInput.spellcheck = false; - const providerList = el("datalist"); - providerList.id = `ai-providers-${id}`; - for (const provider of aiProviders) { - const option = el("option"); - option.value = provider.baseUrl; - option.label = provider.note ? `${provider.name} (${provider.note})` : provider.name; - providerList.appendChild(option); - } - this.baseUrlInput.setAttribute("list", providerList.id); - const baseUrlField = field( - "Base URL", - this.baseUrlInput, - "Requests go to /chat/completions. Clear the field to see all providers in the list.", - ); - baseUrlField.appendChild(providerList); - this.baseUrlInput.addEventListener("input", () => this.editorChanged()); - - this.nameInput = el("input", "yasgui-aiSettings__input"); - this.nameInput.type = "text"; - field("Name", this.nameInput, "Shown in the AI view. The provider or host when empty."); - this.nameInput.addEventListener("input", () => this.editorChanged()); - - this.apiKeyInput = el("input", "yasgui-aiSettings__input"); - this.apiKeyInput.type = "password"; - this.apiKeyInput.autocomplete = "off"; - this.apiKeyInput.spellcheck = false; - this.apiKeyInput.placeholder = "sk-…"; - const keyField = field( - "API key", - this.apiKeyInput, - "Stored in this browser only. It is sent only to the base URL above, and never included in exported settings or share links. Anyone using this browser profile can read it; use a key with a spending limit. Leave it empty for local models and proxies that need no key.", - ); - this.apiKeyInput.addEventListener("input", () => this.editorChanged()); - const keyActions = el("div", "yasgui-aiSettings__row"); - const show = el("button", "secondaryButton", "Show"); - show.type = "button"; - show.addEventListener("click", () => { - const hidden = this.apiKeyInput.type === "password"; - this.apiKeyInput.type = hidden ? "text" : "password"; - show.textContent = hidden ? "Hide" : "Show"; - }); - const forget = el("button", "secondaryButton", "Forget key"); - forget.type = "button"; - forget.addEventListener("click", () => { - this.apiKeyInput.value = ""; - this.editorChanged(); - this.forgetStoredKey(); - this.testResult.textContent = "The API key was removed from this browser."; - }); - keyActions.append(show, forget); - keyField.insertBefore(keyActions, keyField.lastChild); - - this.modelInput = el("input", "yasgui-aiSettings__input"); - this.modelInput.type = "text"; - this.modelInput.spellcheck = false; - this.modelInput.placeholder = config.defaultModel || "e.g. a model id from the list"; - this.modelList = el("datalist"); - this.modelList.id = `ai-models-${id}`; - this.modelInput.setAttribute("list", this.modelList.id); - const modelField = field("Model", this.modelInput, "Use “Test connection” to list the models of the service."); - modelField.appendChild(this.modelList); - this.modelInput.addEventListener("input", () => this.editorChanged()); - - const testRow = el("div", "yasgui-aiSettings__row"); - const test = el("button", "secondaryButton", "Test connection"); - test.type = "button"; - test.addEventListener("click", () => void this.testConnection()); - const done = el("button", "secondaryButton", "Done"); - done.type = "button"; - done.addEventListener("click", () => this.edit(undefined)); - this.testResult = el("div", "settingsHelp yasgui-aiSettings__testResult"); - this.testResult.setAttribute("aria-live", "polite"); - testRow.append(test, done, this.testResult); - this.ownFields.appendChild(testRow); - container.appendChild(this.ownFields); - - // Check queries - if (config.allowCheckQueries) { - const section = el("div", "settingsSection"); - const row = el("div", "checkboxContainer"); - this.checkQueriesInput = el("input"); - this.checkQueriesInput.type = "checkbox"; - this.checkQueriesInput.id = `aiCheckQueries-${id}`; - const label = el("label", undefined, "Let the assistant run check queries on the endpoint"); - label.htmlFor = this.checkQueriesInput.id; - row.append(this.checkQueriesInput, label); - section.appendChild(row); - section.appendChild( - el( - "div", - "settingsHelp", - `Before answering, the assistant may run up to ${config.maxCheckQueries} small read-only queries (at most 20 rows each) to check that classes, properties and values exist. Their results are sent to the AI service, and each one is shown in the answer. Better answers, slightly more data shared.`, - ), - ); - container.appendChild(section); - } + /** Show the stored settings (the list of services) */ + public load() { + if (!this.container) return; + this.draft = undefined; + this.render(); + } - // Consents - this.consentEl = el("div", "settingsSection"); - container.appendChild(this.consentEl); + private render() { + this.container.innerHTML = ""; + if (this.draft) this.renderForm(this.draft); + else this.renderList(); + } - this.load(); + private settings() { + return this.assistant.settings.get(); } - private editing(): AiServiceSettings | undefined { - return this.services.find((service) => service.id === this.editingId); + /** The default service as the AI view uses it: the stored one when it still exists, else the first one */ + private defaultId(): string { + const stored = this.settings().defaultService; + const ids = this.assistant.getConnections().map((connection) => connection.id); + if (stored === AI_SERVICE_OFF || (stored && ids.includes(stored))) return stored; + return ids[0] || AI_SERVICE_OFF; + } + + private providerOfService(service: AiServiceSettings): AiProvider | undefined { + return ( + providerById(service.providerId) || providerOf(service.baseUrl || this.assistant.config.defaultBaseUrl || "") + ); } /** Name shown for one of the user's own services */ private serviceName(service: AiServiceSettings): string { const baseUrl = service.baseUrl || this.assistant.config.defaultBaseUrl; - return service.name?.trim() || providerOf(baseUrl)?.name || hostOf(baseUrl) || "New service"; + const provider = this.providerOfService(service); + return ( + service.name?.trim() || + (provider && provider.id !== CUSTOM_PROVIDER_ID ? provider.name : "") || + hostOf(baseUrl) || + "New service" + ); + } + + private store(changes: Parameters[0]) { + this.assistant.settings.update(changes); + // The new default applies right away, also in the AI view + this.assistant.selectService(undefined); + this.onSaved(); } - private renderServices() { + // ─── The list of services ──────────────────────────────────────────────────────────────────────────────────────── + + private renderList() { const config = this.assistant.config; - this.servicesEl.innerHTML = ""; - const row = (value: string, title: string, help: string, actions: HTMLButtonElement[] = []) => { - const label = el("label", "yasgui-aiSettings__choice"); - const input = el("input"); + const group = el("section", "settingsGroup"); + group.appendChild(el("h3", "settingsGroupTitle", "AI services")); + group.appendChild( + el( + "p", + "settingsGroupLede", + "The AI assistant writes queries from your questions, explains and fixes queries, and explains results. Pick the default service; you can switch in the AI view. Without a service, its actions copy a prompt that you paste in an AI chat of your choice.", + ), + ); + const cards = el("div", "yasgui-aiSettings__cards"); + cards.setAttribute("role", "radiogroup"); + cards.setAttribute("aria-label", "Default AI service"); + group.appendChild(cards); + this.container.appendChild(group); + + const defaultId = this.defaultId(); + const name = controlId("aiDefault"); + const card = ( + value: string, + logo: { text: string; color: string }, + title: string, + badges: Array<[string, string]>, + meta: string, + actions: HTMLElement[] = [], + ) => { + const row = el("div", "yasgui-aiSettings__card"); + const label = el("label", "yasgui-aiSettings__cardMain"); + const input = el("input", "yasgui-aiSettings__radio"); input.type = "radio"; - input.name = `aiDefault-${this.formId}`; + input.name = name; input.value = value; - input.checked = value === this.defaultId; - input.addEventListener("change", () => (this.defaultId = value)); - const text = el("span", "yasgui-aiSettings__choiceText"); - text.appendChild(el("strong", undefined, title)); - text.appendChild(el("span", "yasgui-aiSettings__choiceHelp", help)); - label.append(input, text); + input.checked = value === defaultId; + if (input.checked) row.classList.add("default"); + input.addEventListener("change", () => { + this.store({ defaultService: value }); + this.render(); + }); + const logoEl = el("span", "yasgui-aiSettings__logo", logo.text); + logoEl.style.background = logo.color; + logoEl.setAttribute("aria-hidden", "true"); + const text = el("span", "yasgui-aiSettings__cardText"); + const head = el("span", "yasgui-aiSettings__cardTitle"); + head.appendChild(el("strong", undefined, title)); + if (value === defaultId) head.appendChild(el("span", "yasgui-badge yasgui-badge--default", "Default")); + for (const [badge, kind] of badges) head.appendChild(el("span", `yasgui-badge yasgui-badge--${kind}`, badge)); + text.append(head, el("span", "yasgui-aiSettings__cardMeta", meta)); + label.append(input, logoEl, text); + row.appendChild(label); if (actions.length) { - const buttons = el("span", "yasgui-aiSettings__choiceActions"); - buttons.append(...actions); - label.appendChild(buttons); + const box = el("span", "yasgui-aiSettings__cardActions"); + box.append(...actions); + row.appendChild(box); } - this.servicesEl.appendChild(label); + cards.appendChild(row); }; + siteServices(config).forEach((proxy, index) => { - row( + const title = proxy.label || hostOf(proxy.url); + card( `site:${index}`, - `${proxy.label || hostOf(proxy.url)} (provided by this site)`, - `Requests go to ${hostOf(proxy.url)}. You need no key of your own.`, + { text: title.charAt(0).toUpperCase(), color: "var(--yasgui-accent-color, #0563bb)" }, + title, + [["From this site", "site"]], + `No key needed · ${proxy.model ? proxy.model : "model chosen by the service"} · ${hostOf(proxy.url)}`, ); }); + if (config.allowOwnKey) { - for (const service of this.services) { - const edit = el("button", "secondaryButton", service.id === this.editingId ? "Editing" : "Edit"); + for (const service of this.settings().services || []) { + const provider = this.providerOfService(service); + const baseUrl = normalizeBaseUrl(service.baseUrl || config.defaultBaseUrl); + const serviceName = this.serviceName(service); + const edit = el("button", "yasgui-aiSettings__linkButton", "Edit"); edit.type = "button"; - edit.disabled = service.id === this.editingId; - edit.addEventListener("click", (event) => { - event.preventDefault(); - this.edit(service.id); - }); - const remove = el("button", "secondaryButton", "Remove"); + edit.setAttribute("aria-label", `Edit ${serviceName}`); + edit.addEventListener("click", () => this.edit(service)); + const remove = el("button", "yasgui-aiSettings__linkButton danger", "Remove"); remove.type = "button"; - remove.setAttribute("aria-label", `Remove ${this.serviceName(service)}`); - remove.addEventListener("click", (event) => { - event.preventDefault(); - this.removeService(service.id); - }); - const baseUrl = normalizeBaseUrl(service.baseUrl || config.defaultBaseUrl); - const details = [ + remove.setAttribute("aria-label", `Remove ${serviceName}`); + remove.addEventListener("click", () => this.removeService(service)); + const meta = [ + service.model || config.defaultModel || "no model chosen", hostOf(baseUrl), - service.model || config.defaultModel, - service.apiKey ? "own API key" : isLocalUrl(baseUrl) ? "on your computer" : "no API key", - ].filter(Boolean); - row(service.id, this.serviceName(service), details.join(" · "), [edit, remove]); + service.apiKey + ? `key ••••${service.apiKey.slice(-4)}` + : isLocalUrl(baseUrl) + ? "on your computer" + : "no API key", + ]; + card( + service.id, + { + text: (provider && provider.id !== CUSTOM_PROVIDER_ID ? provider.name : hostOf(baseUrl)) + .charAt(0) + .toUpperCase(), + color: provider?.color || "#808893", + }, + serviceName, + provider?.region ? [[provider.region, "region"]] : [], + meta.join(" · "), + [edit, remove], + ); } } - row(AI_SERVICE_OFF, "No AI service", "Nothing is sent. The AI actions copy a prompt to the clipboard."); - } + card( + AI_SERVICE_OFF, + { text: "∅", color: "#808893" }, + "No AI service", + [], + "Nothing is sent. The AI actions copy a prompt to the clipboard.", + ); - private addService() { - const service: AiServiceSettings = { id: newServiceId() }; - this.services.push(service); - if (this.defaultId === AI_SERVICE_OFF && !siteServices(this.assistant.config).length) this.defaultId = service.id; - this.edit(service.id); - this.baseUrlInput.focus(); - } + if (config.allowOwnKey) { + const add = el("button", "yasgui-aiSettings__add"); + add.type = "button"; + add.innerHTML = icon("plus", 14) + "Add AI service"; + add.addEventListener("click", () => this.addService()); + cards.appendChild(add); + } - private removeService(serviceId: string) { - this.services = this.services.filter((service) => service.id !== serviceId); - if (this.defaultId === serviceId) { - const sites = siteServices(this.assistant.config); - this.defaultId = sites.length ? "site:0" : this.services[0]?.id || AI_SERVICE_OFF; + // Behaviour of the assistant + const behaviour = settingsGroup(this.container, "Assistant behaviour"); + if (config.allowCheckQueries) { + const id = controlId("aiCheckQueries"); + settingsRow( + behaviour, + "Check queries on the endpoint", + `Before answering, the assistant may run up to ${config.maxCheckQueries} small read-only queries (at most 20 rows each) to check that classes, properties and values exist. Their results go to the AI service and are shown in the answer.`, + [settingsSwitch(id, this.settings().checkQueries !== false, (on) => this.store({ checkQueries: on }))], + id, + ); } - if (this.editingId === serviceId) this.edit(undefined); - else this.renderServices(); + const hosts = this.settings().consentedHosts || []; + const reset = el("button", "secondaryButton", "Ask again"); + reset.type = "button"; + reset.addEventListener("click", () => { + this.store({ consentedHosts: undefined }); + this.render(); + }); + settingsRow( + behaviour, + "Privacy notice", + hosts.length + ? `Ask before sending. You chose not to be asked again for ${hosts.join(", ")}.` + : "Before sending a request, the assistant shows what it sends and asks you to confirm.", + hosts.length ? [reset] : [], + ); } - /** Show the editor for one of the user's own services, or hide it */ - private edit(serviceId: string | undefined) { - this.editingId = serviceId; - const service = this.editing(); - this.ownFields.hidden = !service; - if (service) { - this.editorTitle.textContent = `Service: ${this.serviceName(service)}`; - this.baseUrlInput.value = service.baseUrl || ""; - this.nameInput.value = service.name || ""; - this.nameInput.placeholder = this.serviceName({ ...service, name: undefined }); - this.apiKeyInput.value = service.apiKey || ""; - this.apiKeyInput.type = "password"; - this.modelInput.value = service.model || ""; - this.modelList.innerHTML = ""; - this.testResult.textContent = ""; - this.testResult.classList.remove("ok"); - } - this.renderServices(); + private addService() { + const config = this.assistant.config; + const provider = providerOf(config.defaultBaseUrl) || aiProviders[0]; + const noDefault = this.settings().defaultService === AI_SERVICE_OFF || !this.assistant.getConnections().length; + this.draft = { + id: newServiceId(), + isNew: true, + providerId: provider.id, + baseUrl: provider.baseUrl, + model: config.defaultModel, + makeDefault: noDefault || !siteServices(config).length, + }; + this.render(); } - private editorChanged() { - const service = this.editing(); - if (!service) return; - service.baseUrl = this.baseUrlInput.value.trim() || undefined; - service.name = this.nameInput.value.trim() || undefined; - service.apiKey = this.apiKeyInput.value.trim() || undefined; - service.model = this.modelInput.value.trim() || undefined; - this.nameInput.placeholder = this.serviceName({ ...service, name: undefined }); - this.editorTitle.textContent = `Service: ${this.serviceName(service)}`; - this.renderServices(); + private edit(service: AiServiceSettings) { + const provider = this.providerOfService(service); + this.draft = { + ...service, + providerId: provider?.id || CUSTOM_PROVIDER_ID, + baseUrl: service.baseUrl || this.assistant.config.defaultBaseUrl, + isNew: false, + makeDefault: this.defaultId() === service.id, + }; + this.render(); } - /** "Forget key" removes the key from storage right away, not only when the settings are saved */ - private forgetStoredKey() { - const stored = this.assistant.settings.get().services || []; - if (stored.some((service) => service.id === this.editingId && service.apiKey)) { - this.assistant.settings.update({ - services: stored.map((service) => - service.id === this.editingId ? { ...service, apiKey: undefined } : service, - ), - }); - } + private removeService(service: AiServiceSettings) { + if (!confirm(`Remove the AI service “${this.serviceName(service)}” and its API key from this browser?`)) return; + const services = (this.settings().services || []).filter((candidate) => candidate.id !== service.id); + const changes: Parameters[0] = { + services: services.length ? services : undefined, + }; + if (this.settings().defaultService === service.id) changes.defaultService = undefined; + this.store(changes); + this.render(); } - private renderConsents() { - const hosts = this.assistant.settings.get().consentedHosts || []; - this.consentEl.innerHTML = ""; - this.consentEl.appendChild(el("label", "settingsLabel", "Privacy notice")); - if (!hosts.length) { - this.consentEl.appendChild( - el( - "div", - "settingsHelp", - "Before sending a request, the assistant shows what it sends and asks you to confirm.", - ), - ); - return; - } - this.consentEl.appendChild(el("div", "settingsHelp", `You chose not to be asked again for: ${hosts.join(", ")}.`)); - const reset = el("button", "secondaryButton", "Ask me again"); - reset.type = "button"; - reset.addEventListener("click", () => { - this.assistant.settings.update({ consentedHosts: undefined }); - this.renderConsents(); + // ─── The form of one service ───────────────────────────────────────────────────────────────────────────────────── + + private renderForm(draft: Draft) { + const config = this.assistant.config; + const crumb = el("div", "yasgui-aiSettings__crumb"); + const back = el("button", "yasgui-aiSettings__back", "‹ AI services"); + back.type = "button"; + back.addEventListener("click", () => this.load()); + crumb.append( + back, + el("span", undefined, "/"), + el("span", undefined, draft.isNew ? "New AI service" : this.serviceName(draft)), + ); + this.container.appendChild(crumb); + + const form = el("div", "yasgui-aiSettings__form"); + const head = el("div", "yasgui-aiSettings__formTitle"); + head.appendChild(el("strong", undefined, draft.isNew ? "New AI service" : `Edit ${this.serviceName(draft)}`)); + head.appendChild(el("span", undefined, "Any service with an OpenAI-compatible API")); + form.appendChild(head); + + const field = (labelText: string, control: HTMLElement, extra?: HTMLElement[], hint?: string, forId?: string) => { + const wrapper = el("div", "yasgui-aiSettings__field"); + const label = el("div", "yasgui-aiSettings__fieldLabel"); + const name = el(forId ? "label" : "span", undefined, labelText); + if (forId) (name as HTMLLabelElement).htmlFor = forId; + label.appendChild(name); + if (extra) label.append(...extra); + wrapper.append(label, control); + if (hint) wrapper.appendChild(el("div", "yasgui-aiSettings__hint", hint)); + return wrapper; + }; + const provider = () => providerById(draft.providerId) || providerById(CUSTOM_PROVIDER_ID)!; + + // Provider and name + const providerCombo = new Combobox({ + ariaLabel: "Provider", + placeholder: "Pick a provider", + searchPlaceholder: "Search providers", + onChange: (id) => { + const picked = providerById(id)!; + draft.providerId = picked.id; + draft.baseUrl = picked.baseUrl; + draft.model = picked.recommended?.[0]; + this.render(); + if (picked.id === CUSTOM_PROVIDER_ID || picked.id === "azure") { + (this.container.querySelector(".yasgui-aiSettings__baseUrl") as HTMLInputElement | null)?.focus(); + } + }, }); - this.consentEl.appendChild(reset); - } + providerCombo.setOptions( + aiProviders.map((candidate) => ({ + value: candidate.id, + label: candidate.name, + group: providerGroupLabels[candidate.group], + sub: candidate.note, + badge: candidate.region, + logo: { text: candidate.id === CUSTOM_PROVIDER_ID ? "+" : candidate.name.charAt(0), color: candidate.color }, + })), + ); + providerCombo.setValue(draft.providerId); + const nameInput = el("input", "yasgui-aiSettings__input"); + nameInput.type = "text"; + nameInput.id = controlId("aiName"); + nameInput.value = draft.name || ""; + nameInput.placeholder = this.serviceName({ ...draft, name: undefined }); + nameInput.addEventListener("input", () => (draft.name = nameInput.value.trim() || undefined)); + const pair = el("div", "yasgui-aiSettings__pair"); + pair.append( + field("Provider", providerCombo.el), + field( + "Name", + nameInput, + [el("span", "yasgui-aiSettings__aside", "shown in the AI view")], + undefined, + nameInput.id, + ), + ); + form.appendChild(pair); - /** Fill the form from the stored settings */ - public load() { - if (!this.baseUrlInput) return; - const settings = this.assistant.settings.get(); - this.services = (settings.services || []).map((service) => ({ ...service })); - const ids = this.assistant.getConnections().map((connection) => connection.id); - this.defaultId = - settings.defaultService === AI_SERVICE_OFF || ids.includes(settings.defaultService || "") - ? settings.defaultService! - : ids[0] || AI_SERVICE_OFF; - if (this.checkQueriesInput) this.checkQueriesInput.checked = settings.checkQueries !== false; - // Someone with only their own service most likely came to change it - const own = this.services.length === 1 && !siteServices(this.assistant.config).length; - this.edit(own ? this.services[0].id : undefined); - this.renderConsents(); - } + // Base URL: the provider's, until the user changes it + const baseInput = el("input", "yasgui-aiSettings__input yasgui-aiSettings__baseUrl"); + baseInput.type = "url"; + baseInput.id = controlId("aiBaseUrl"); + baseInput.spellcheck = false; + baseInput.value = draft.baseUrl || ""; + baseInput.placeholder = "https://…/v1"; + const fromProvider = el("span", "yasgui-pill", "From provider"); + const changed = el("span", "yasgui-pill yasgui-pill--changed", "Changed"); + const resetBase = el("button", "yasgui-aiSettings__linkButton", "Reset to provider default"); + resetBase.type = "button"; + const showBaseState = () => { + const preset = provider().baseUrl; + const same = !!preset && normalizeBaseUrl(baseInput.value) === normalizeBaseUrl(preset); + fromProvider.hidden = !same; + changed.hidden = same || !preset; + resetBase.hidden = same || !preset; + }; + resetBase.addEventListener("click", () => { + baseInput.value = provider().baseUrl; + draft.baseUrl = baseInput.value; + showBaseState(); + }); + baseInput.addEventListener("input", () => { + draft.baseUrl = baseInput.value.trim() || undefined; + showBaseState(); + }); + showBaseState(); + form.appendChild( + field( + "Base URL", + baseInput, + [fromProvider, changed, resetBase], + provider().id === CUSTOM_PROVIDER_ID + ? "Requests go to /chat/completions." + : provider().note && provider().id === "azure" + ? "Replace YOUR-RESOURCE with the name of your Azure resource." + : "Filled in from the provider. Change it for a gateway, a proxy or a self-hosted server.", + baseInput.id, + ), + ); - public save() { - if (!this.baseUrlInput) return; - const services = this.services - .filter((service) => service.baseUrl || service.apiKey || service.model || service.name) - .map((service) => { - const cleaned: AiServiceSettings = { id: service.id }; - if (service.name) cleaned.name = service.name; - if (service.baseUrl) cleaned.baseUrl = normalizeBaseUrl(service.baseUrl); - if (service.apiKey) cleaned.apiKey = service.apiKey; - if (service.model) cleaned.model = service.model; - return cleaned; + // API key + if (!provider().noKey) { + const keyBox = el("div", "yasgui-aiSettings__keyBox"); + const keyInput = el("input", "yasgui-aiSettings__input"); + keyInput.type = "password"; + keyInput.id = controlId("aiKey"); + keyInput.autocomplete = "off"; + keyInput.spellcheck = false; + keyInput.placeholder = "sk-…"; + keyInput.value = draft.apiKey || ""; + keyInput.addEventListener("input", () => (draft.apiKey = keyInput.value.trim() || undefined)); + const eye = el("button", "yasgui-aiSettings__eye"); + eye.type = "button"; + eye.setAttribute("aria-label", "Show the API key"); + eye.textContent = "Show"; + eye.addEventListener("click", () => { + const hidden = keyInput.type === "password"; + keyInput.type = hidden ? "text" : "password"; + eye.textContent = hidden ? "Hide" : "Show"; + eye.setAttribute("aria-label", hidden ? "Hide the API key" : "Show the API key"); }); - const kept = this.defaultId === AI_SERVICE_OFF || this.defaultId.startsWith("site:"); - this.assistant.settings.update({ - services: services.length ? services : undefined, - defaultService: kept || services.some((service) => service.id === this.defaultId) ? this.defaultId : undefined, - checkQueries: this.checkQueriesInput ? this.checkQueriesInput.checked : undefined, + keyBox.append(keyInput, eye); + const extra: HTMLElement[] = []; + const keyUrl = provider().keyUrl; + if (keyUrl) { + const link = el("a", "yasgui-aiSettings__keyLink", `Get a key at ${hostOf(keyUrl)} ↗`); + link.href = keyUrl; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + extra.push(link); + } + const keyField = field( + "API key", + keyBox, + extra, + "Stored in this browser only, and sent only to the base URL above; never exported or shared. Use a key with a spending limit. Leave it empty for a proxy that needs no key.", + keyInput.id, + ); + const stored = (this.settings().services || []).find((service) => service.id === draft.id); + if (stored?.apiKey) { + // Removes the key from this browser right away, also when the form is cancelled + const forget = el("button", "yasgui-aiSettings__linkButton danger yasgui-aiSettings__forget", "Forget key"); + forget.type = "button"; + forget.addEventListener("click", () => { + this.store({ + services: (this.settings().services || []).map((service) => + service.id === draft.id ? { ...service, apiKey: undefined } : service, + ), + }); + draft.apiKey = undefined; + keyInput.value = ""; + forget.replaceWith(el("span", "yasgui-aiSettings__hint", "The API key was removed from this browser.")); + }); + keyField.appendChild(forget); + } + form.appendChild(keyField); + } + + // Model: a list fetched from the service, the provider's recommended models, or any id typed in + const modelCombo = new Combobox({ + ariaLabel: "Model", + placeholder: "Fetch models, or type a model id", + searchPlaceholder: "Search models or type a model id", + mono: true, + allowCustom: true, + onChange: (id) => (draft.model = id), }); - // The new default applies right away, also in the AI view - this.assistant.selectService(undefined); - } + modelCombo.setValue(draft.model); + const fetchButton = el("button", "secondaryButton yasgui-aiSettings__fetch"); + fetchButton.type = "button"; + fetchButton.innerHTML = icon("refresh", 14) + "Fetch models"; + const modelRow = el("div", "yasgui-aiSettings__modelRow"); + modelRow.append(modelCombo.el, fetchButton); + const status = el("div", "yasgui-aiSettings__status"); + status.setAttribute("aria-live", "polite"); + const notice = el("div", "yasgui-aiSettings__notice"); + notice.hidden = true; + const modelField = field("Model", modelRow); + modelField.append(status, notice); + form.appendChild(modelField); - private async testConnection() { - const service = this.editing(); - const connection = service && ownConnection(this.assistant.config, service); - if (!connection) return; - const baseUrl = connection.baseUrl; - this.testResult.classList.remove("ok"); - this.testResult.textContent = `Connecting to ${hostOf(baseUrl)}…`; - try { - const models = await listModels(connection); - this.modelList.innerHTML = ""; - for (const model of models) { - const option = el("option"); - option.value = model; - this.modelList.appendChild(option); + const showModels = (models: FoundModels | undefined) => { + const recommended = provider().recommended || []; + const ids = models ? models.ids : recommended; + const starred = recommended.filter((id) => ids.includes(id)); + const options: ComboOption[] = [ + ...starred.map((id) => ({ value: id, label: id, star: true, group: "Recommended" })), + ...ids + .filter((id) => !starred.includes(id)) + .map((id) => ({ value: id, label: id, group: starred.length ? "All models" : undefined })), + ]; + const footer = models?.hidden ? `${models.hidden} embedding, speech and image models hidden` : undefined; + modelCombo.setOptions(options, footer); + modelCombo.setSearchPlaceholder( + ids.length ? `Search ${ids.length} models or type a model id` : "Type a model id", + ); + }; + showModels(this.found.get(normalizeBaseUrl(draft.baseUrl || ""))); + + const fetchModels = async () => { + const connection = ownConnection(config, draft); + status.className = "yasgui-aiSettings__status"; + notice.hidden = true; + if (!connection) { + status.textContent = "Enter the base URL first."; + return; + } + fetchButton.disabled = true; + status.textContent = `Fetching the models of ${connection.host}…`; + try { + const all = await listModels(connection); + const ids = all.filter(isChatModel); + const models = { ids, fromService: true, hidden: all.length - ids.length }; + this.found.set(normalizeBaseUrl(connection.baseUrl), models); + showModels(models); + if (!draft.model || !ids.includes(draft.model)) { + const pick = + (provider().recommended || []).find((id) => ids.includes(id)) || (draft.model ? undefined : ids[0]); + if (pick) { + draft.model = pick; + modelCombo.setValue(pick); + } + } + status.classList.add("ok"); + status.textContent = ids.length + ? `Connected · ${ids.length} models found` + : "Connected. The service doesn't list its models; type the model id."; + } catch (e: any) { + const recommended = provider().recommended || []; + status.classList.add(e instanceof AiRequestError && e.unreachable ? "warn" : "error"); + if (e instanceof AiRequestError && e.unreachable) { + showModels(undefined); + status.textContent = recommended.length + ? `The model list can't be read from the browser. Showing the ${recommended.length} models MatGUI knows for ${provider().name}.` + : "The model list can't be read from the browser. Type the model id."; + notice.textContent = + provider().corsHint || + `${connection.host} doesn't let a browser read its models (CORS), or it can't be reached. You can still type a model id. If questions to the assistant fail the same way, use the service through a proxy of this site or through OpenRouter.`; + notice.hidden = false; + } else { + status.textContent = e?.message || `Could not fetch the models of ${connection.host}.`; + } + } finally { + fetchButton.disabled = false; } - this.testResult.textContent = models.length - ? `Connected. ${models.length} models available; pick one in the Model field.` - : "Connected. The service doesn't list its models; enter the model id yourself."; - addClass(this.testResult, "ok"); - } catch (e: any) { - this.testResult.classList.remove("ok"); - this.testResult.textContent = - e?.message && e.name === "AiRequestError" - ? e.message - : `Could not reach ${hostOf(baseUrl)}. Check the base URL and whether the service allows requests from a browser (CORS).`; + }; + fetchButton.addEventListener("click", () => void fetchModels()); + + // Default, Cancel and Save + const footer = el("div", "yasgui-aiSettings__formFooter"); + const defaultId = controlId("aiMakeDefault"); + const defaultLabel = el("label", "yasgui-aiSettings__defaultSwitch"); + defaultLabel.htmlFor = defaultId; + defaultLabel.append( + settingsSwitch(defaultId, draft.makeDefault, (on) => (draft.makeDefault = on)), + el("span", undefined, "Make this the default service"), + ); + const cancel = el("button", "secondaryButton", "Cancel"); + cancel.type = "button"; + cancel.addEventListener("click", () => this.load()); + const save = el("button", "primaryButton", "Save service"); + save.type = "button"; + save.addEventListener("click", () => this.saveDraft(draft, status)); + footer.append(defaultLabel, cancel, save); + form.appendChild(footer); + + this.container.appendChild(form); + } + + private saveDraft(draft: Draft, status: HTMLElement) { + if (!draft.baseUrl?.trim()) { + status.className = "yasgui-aiSettings__status error"; + status.textContent = "Enter the base URL of the service."; + (this.container.querySelector(".yasgui-aiSettings__baseUrl") as HTMLInputElement | null)?.focus(); + return; } + const cleaned: AiServiceSettings = { id: draft.id, baseUrl: normalizeBaseUrl(draft.baseUrl) }; + if (draft.providerId && draft.providerId !== CUSTOM_PROVIDER_ID) cleaned.providerId = draft.providerId; + if (draft.name) cleaned.name = draft.name; + if (draft.apiKey && !providerById(draft.providerId)?.noKey) cleaned.apiKey = draft.apiKey; + if (draft.model) cleaned.model = draft.model; + const services = [...(this.settings().services || [])]; + const index = services.findIndex((service) => service.id === draft.id); + if (index >= 0) services[index] = cleaned; + else services.push(cleaned); + const changes: Parameters[0] = { services }; + if (draft.makeDefault) changes.defaultService = draft.id; + else if (this.settings().defaultService === draft.id) changes.defaultService = undefined; + this.store(changes); + this.load(); } } diff --git a/packages/yasgui/src/ai/Combobox.ts b/packages/yasgui/src/ai/Combobox.ts new file mode 100644 index 00000000..8157e7e0 --- /dev/null +++ b/packages/yasgui/src/ai/Combobox.ts @@ -0,0 +1,278 @@ +/** + * A dropdown with a search field, grouped options and optionally free text: the Provider and Model fields of the AI + * settings. The list opens below the field (above it when there is no room), in fixed position, so the scrolling + * settings page doesn't clip it. + */ +import { icon } from "../icons"; + +export interface ComboOption { + value: string; + label: string; + /** Options with the same group are listed under that heading, in the order of the options */ + group?: string; + /** Short text at the right of the option, e.g. "no key" */ + sub?: string; + /** Small badge after the label, e.g. "EU" */ + badge?: string; + /** Recommended option, marked with a star */ + star?: boolean; + /** Coloured initial shown as a logo */ + logo?: { text: string; color: string }; +} + +export interface ComboboxOptions { + ariaLabel: string; + placeholder: string; + searchPlaceholder: string; + /** Labels and values in a monospace font (model ids) */ + mono?: boolean; + /** Offer what is typed in the search field as a value of its own ("Use …") */ + allowCustom?: boolean; + onChange: (value: string) => void; +} + +function el(tag: K, className?: string, text?: string) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +function logoEl(logo: { text: string; color: string }) { + const element = el("span", "yasgui-combo__logo", logo.text); + element.style.background = logo.color; + return element; +} + +let comboCount = 0; + +export default class Combobox { + public readonly el: HTMLDivElement; + private button: HTMLButtonElement; + private pop: HTMLDivElement; + private search: HTMLInputElement; + private list: HTMLDivElement; + private footer: HTMLDivElement; + private options: ComboOption[] = []; + private value: string | undefined; + private highlighted = 0; + private visible: ComboOption[] = []; + private listId = `yasgui-combo-${++comboCount}`; + + constructor(private config: ComboboxOptions) { + this.el = el("div", "yasgui-combo" + (config.mono ? " yasgui-combo--mono" : "")); + this.button = el("button", "yasgui-combo__button"); + this.button.type = "button"; + this.button.setAttribute("aria-haspopup", "listbox"); + this.button.setAttribute("aria-expanded", "false"); + this.button.setAttribute("aria-label", config.ariaLabel); + this.button.addEventListener("click", () => (this.isOpen() ? this.close() : this.open())); + this.button.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + this.open(); + } + }); + + this.pop = el("div", "yasgui-combo__pop"); + this.pop.hidden = true; + this.search = el("input", "yasgui-combo__search"); + this.search.type = "search"; + this.search.placeholder = config.searchPlaceholder; + this.search.spellcheck = false; + this.search.autocomplete = "off"; + this.search.setAttribute("role", "combobox"); + this.search.setAttribute("aria-controls", this.listId); + this.search.setAttribute("aria-label", config.ariaLabel); + this.search.addEventListener("input", () => { + this.highlighted = 0; + this.renderList(); + }); + this.search.addEventListener("keydown", (event) => this.onKey(event)); + this.list = el("div", "yasgui-combo__list"); + this.list.id = this.listId; + this.list.setAttribute("role", "listbox"); + this.footer = el("div", "yasgui-combo__footer"); + this.pop.append(this.search, this.list, this.footer); + this.el.append(this.button, this.pop); + this.renderButton(); + } + + public setOptions(options: ComboOption[], footer?: string) { + this.options = options; + this.footer.textContent = footer || ""; + this.footer.hidden = !footer; + this.renderButton(); + if (this.isOpen()) this.renderList(); + } + + public setValue(value: string | undefined) { + this.value = value || undefined; + this.renderButton(); + } + + public getValue(): string | undefined { + return this.value; + } + + public setSearchPlaceholder(text: string) { + this.search.placeholder = text; + } + + public focus() { + this.button.focus(); + } + + public isOpen() { + return !this.pop.hidden; + } + + public open() { + if (this.isOpen()) return; + this.pop.hidden = false; + this.button.setAttribute("aria-expanded", "true"); + this.el.classList.add("open"); + this.search.value = ""; + const selected = this.options.findIndex((option) => option.value === this.value); + this.highlighted = Math.max(0, selected); + this.renderList(); + this.place(); + this.search.focus(); + document.addEventListener("mousedown", this.onOutside, true); + window.addEventListener("resize", this.onViewportChange); + document.addEventListener("scroll", this.onScroll, true); + } + + public close(focusButton = false) { + if (!this.isOpen()) return; + this.pop.hidden = true; + this.button.setAttribute("aria-expanded", "false"); + this.el.classList.remove("open"); + document.removeEventListener("mousedown", this.onOutside, true); + window.removeEventListener("resize", this.onViewportChange); + document.removeEventListener("scroll", this.onScroll, true); + if (focusButton) this.button.focus(); + } + + private onOutside = (event: MouseEvent) => { + if (!this.el.contains(event.target as Node)) this.close(); + }; + + private onViewportChange = () => this.close(); + + private onScroll = (event: Event) => { + if (!this.pop.contains(event.target as Node)) this.close(); + }; + + /** Fixed position below the field, or above it when there is more room there */ + private place() { + const rect = this.button.getBoundingClientRect(); + const below = window.innerHeight - rect.bottom - 12; + const above = rect.top - 12; + const up = below < 260 && above > below; + const room = Math.max(160, Math.min(360, up ? above : below)); + this.pop.style.left = `${rect.left}px`; + this.pop.style.width = `${Math.max(rect.width, 280)}px`; + this.pop.style.maxHeight = `${room}px`; + this.pop.style.top = up ? "" : `${rect.bottom + 4}px`; + this.pop.style.bottom = up ? `${window.innerHeight - rect.top + 4}px` : ""; + } + + private onKey(event: KeyboardEvent) { + if (event.key === "Escape") { + // Closes the list only, not the settings + event.preventDefault(); + event.stopPropagation(); + this.close(true); + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (!this.visible.length) return; + const step = event.key === "ArrowDown" ? 1 : -1; + this.highlighted = (this.highlighted + step + this.visible.length) % this.visible.length; + this.renderList(); + } else if (event.key === "Enter") { + event.preventDefault(); + const option = this.visible[this.highlighted]; + if (option) this.pick(option.value); + } else if (event.key === "Tab") { + this.close(); + } + } + + private pick(value: string) { + this.value = value; + this.renderButton(); + this.close(true); + this.config.onChange(value); + } + + private renderButton() { + this.button.innerHTML = ""; + const option = this.options.find((candidate) => candidate.value === this.value); + if (option?.logo) this.button.appendChild(logoEl(option.logo)); + const label = el( + "span", + "yasgui-combo__value" + (this.value ? "" : " placeholder"), + option?.label || this.value || this.config.placeholder, + ); + this.button.appendChild(label); + if (option?.badge) this.button.appendChild(el("span", "yasgui-badge yasgui-badge--region", option.badge)); + if (option?.star) this.button.appendChild(el("span", "yasgui-combo__sub", "recommended")); + const chevron = el("span", "yasgui-combo__chevron"); + chevron.innerHTML = icon("chevronDown", 14); + this.button.appendChild(chevron); + } + + private renderList() { + const words = this.search.value.trim().toLowerCase().split(/\s+/).filter(Boolean); + const matches = (option: ComboOption) => + words.every((word) => `${option.label} ${option.value} ${option.sub || ""}`.toLowerCase().includes(word)); + this.visible = this.options.filter(matches); + const typed = this.search.value.trim(); + if (this.config.allowCustom && typed && !this.options.some((option) => option.value === typed)) { + this.visible.push({ value: typed, label: `Use “${typed}”`, group: "Other model id" }); + } + if (this.highlighted >= this.visible.length) this.highlighted = 0; + + this.list.innerHTML = ""; + if (!this.visible.length) { + this.list.appendChild(el("div", "yasgui-combo__empty", "Nothing found")); + return; + } + let group: string | undefined; + this.visible.forEach((option, index) => { + if (option.group && option.group !== group) { + group = option.group; + this.list.appendChild(el("div", "yasgui-combo__group", group)); + } + const row = el("div", "yasgui-combo__option"); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", String(option.value === this.value)); + row.id = `${this.listId}-${index}`; + if (index === this.highlighted) { + row.classList.add("highlighted"); + this.search.setAttribute("aria-activedescendant", row.id); + } + if (option.star) row.appendChild(el("span", "yasgui-combo__star", "★")); + if (option.logo) row.appendChild(logoEl(option.logo)); + row.appendChild(el("span", "yasgui-combo__label", option.label)); + if (option.badge) row.appendChild(el("span", "yasgui-badge yasgui-badge--region", option.badge)); + if (option.sub) row.appendChild(el("span", "yasgui-combo__sub", option.sub)); + if (option.value === this.value) { + const check = el("span", "yasgui-combo__check"); + check.innerHTML = icon("check", 14); + row.appendChild(check); + } + row.addEventListener("mousedown", (event) => event.preventDefault()); + row.addEventListener("click", () => this.pick(option.value)); + row.addEventListener("mousemove", () => { + if (this.highlighted === index) return; + this.list.querySelector(".highlighted")?.classList.remove("highlighted"); + row.classList.add("highlighted"); + this.highlighted = index; + }); + this.list.appendChild(row); + }); + this.list.querySelector(".highlighted")?.scrollIntoView({ block: "nearest" }); + } +} diff --git a/packages/yasgui/src/ai/openaiClient.ts b/packages/yasgui/src/ai/openaiClient.ts index 00531da4..cf5e6e39 100644 --- a/packages/yasgui/src/ai/openaiClient.ts +++ b/packages/yasgui/src/ai/openaiClient.ts @@ -40,6 +40,8 @@ export class AiRequestError extends Error { public status?: number, /** True when the service seems to reject the `tools` parameter */ public toolsUnsupported = false, + /** True when the request got no answer at all: the browser blocked it (CORS) or the host is unreachable */ + public unreachable = false, ) { super(message); this.name = "AiRequestError"; @@ -153,18 +155,31 @@ export async function chatCompletion(connection: AiConnection, request: ChatRequ /** Model ids offered by the service, sorted; an empty list when the service doesn't list models */ export async function listModels(connection: AiConnection, signal?: AbortSignal): Promise { - const response = await fetch(joinUrl(connection.baseUrl, "models"), { - headers: await buildHeaders(connection), - signal, - credentials: connection.withCredentials ? "include" : "omit", - }); + let response: Response; + try { + response = await fetch(joinUrl(connection.baseUrl, "models"), { + headers: await buildHeaders(connection), + signal, + credentials: connection.withCredentials ? "include" : "omit", + }); + } catch (e: any) { + if (e?.name === "AbortError") throw e; + throw new AiRequestError( + `Could not reach ${connection.host}: the service doesn't let a browser read its models (CORS), or it is offline.`, + undefined, + false, + true, + ); + } const text = await response.text(); if (!response.ok) throw new AiRequestError(describeErrorBody(response.status, text), response.status); try { const json = JSON.parse(text); const ids: string[] = (json?.data || json?.models || []) .map((m: any) => (typeof m === "string" ? m : m?.id || m?.name)) - .filter((id: unknown): id is string => typeof id === "string"); + .filter((id: unknown): id is string => typeof id === "string") + // Gemini lists "models/gemini-…", and takes the id without that prefix + .map((id: string) => id.replace(/^models\//, "")); return Array.from(new Set(ids)).sort(); } catch { return []; diff --git a/packages/yasgui/src/ai/providers.ts b/packages/yasgui/src/ai/providers.ts index 394e4bb9..040a29c8 100644 --- a/packages/yasgui/src/ai/providers.ts +++ b/packages/yasgui/src/ai/providers.ts @@ -1,38 +1,210 @@ /** - * Popular services with an OpenAI-compatible API, suggested in the Base URL field of the AI settings. - * Users can still enter any other URL. + * Popular services with an OpenAI-compatible API, offered in the Provider list of the AI settings. Picking one fills + * in the base URL, which users can still change (a gateway, a proxy, a self-hosted server), and "Custom" takes any + * other URL. */ +export type AiProviderGroup = "popular" | "cloud" | "local" | "other"; + export interface AiProvider { + /** Stored with the service, so the form shows the same provider when it is edited */ + id: string; name: string; + /** Empty for "Custom" */ baseUrl: string; + group: AiProviderGroup; /** Shown next to the name in the list */ note?: string; + /** "EU" for services hosted in the European Union */ + region?: string; + /** Where users create an API key */ + keyUrl?: string; + /** Local services and gateways that take no key */ + noKey?: boolean; + /** Colour of the initial shown as the provider's logo */ + color: string; + /** + * Models shown first (with a star) in the Model list, and the list offered when the service doesn't let a browser + * read its models. Users can always type another model id. + */ + recommended?: string[]; + /** What to do when the browser can't reach the service (CORS), for local servers */ + corsHint?: string; } +export const CUSTOM_PROVIDER_ID = "custom"; + export const aiProviders: AiProvider[] = [ - { name: "OpenAI", baseUrl: "https://api.openai.com/v1" }, - { name: "Anthropic (Claude)", baseUrl: "https://api.anthropic.com/v1" }, - { name: "Google Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai" }, - { name: "Mistral", baseUrl: "https://api.mistral.ai/v1" }, - { name: "Moonshot (Kimi)", baseUrl: "https://api.moonshot.ai/v1" }, - { name: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" }, - { name: "xAI (Grok)", baseUrl: "https://api.x.ai/v1" }, - { name: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", note: "many providers with one key" }, - { name: "Groq", baseUrl: "https://api.groq.com/openai/v1" }, - { name: "Together AI", baseUrl: "https://api.together.xyz/v1" }, - { name: "Fireworks AI", baseUrl: "https://api.fireworks.ai/inference/v1" }, - { name: "Cerebras", baseUrl: "https://api.cerebras.ai/v1" }, - { name: "Hugging Face", baseUrl: "https://router.huggingface.co/v1" }, - { name: "Scaleway", baseUrl: "https://api.scaleway.ai/v1", note: "hosted in the EU" }, { + id: "openai", + name: "OpenAI", + baseUrl: "https://api.openai.com/v1", + group: "popular", + keyUrl: "https://platform.openai.com/api-keys", + color: "#10a37f", + recommended: ["gpt-5", "gpt-5-mini"], + }, + { + id: "anthropic", + name: "Anthropic (Claude)", + baseUrl: "https://api.anthropic.com/v1", + group: "popular", + keyUrl: "https://console.anthropic.com/settings/keys", + color: "#c96442", + recommended: ["claude-sonnet-5-5", "claude-opus-5-5", "claude-haiku-5-5"], + }, + { + id: "gemini", + name: "Google Gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + group: "popular", + keyUrl: "https://aistudio.google.com/apikey", + color: "#1a73e8", + recommended: ["gemini-2.5-pro", "gemini-2.5-flash"], + }, + { + id: "mistral", + name: "Mistral", + baseUrl: "https://api.mistral.ai/v1", + group: "popular", + region: "EU", + keyUrl: "https://console.mistral.ai/api-keys", + color: "#fa520f", + recommended: ["mistral-medium-latest", "mistral-large-latest", "codestral-latest"], + }, + { + id: "openrouter", + name: "OpenRouter", + baseUrl: "https://openrouter.ai/api/v1", + group: "popular", + note: "many models, one key", + keyUrl: "https://openrouter.ai/keys", + color: "#6d28d9", + recommended: ["openrouter/auto"], + }, + { + id: "deepseek", + name: "DeepSeek", + baseUrl: "https://api.deepseek.com/v1", + group: "cloud", + keyUrl: "https://platform.deepseek.com/api_keys", + color: "#4d6bfe", + recommended: ["deepseek-chat", "deepseek-reasoner"], + }, + { + id: "xai", + name: "xAI (Grok)", + baseUrl: "https://api.x.ai/v1", + group: "cloud", + keyUrl: "https://console.x.ai", + color: "#1c2633", + }, + { + id: "moonshot", + name: "Moonshot (Kimi)", + baseUrl: "https://api.moonshot.ai/v1", + group: "cloud", + keyUrl: "https://platform.moonshot.ai/console/api-keys", + color: "#16191e", + }, + { + id: "groq", + name: "Groq", + baseUrl: "https://api.groq.com/openai/v1", + group: "cloud", + keyUrl: "https://console.groq.com/keys", + color: "#f55036", + }, + { + id: "together", + name: "Together AI", + baseUrl: "https://api.together.xyz/v1", + group: "cloud", + keyUrl: "https://api.together.ai/settings/api-keys", + color: "#0f6fff", + }, + { + id: "fireworks", + name: "Fireworks AI", + baseUrl: "https://api.fireworks.ai/inference/v1", + group: "cloud", + keyUrl: "https://fireworks.ai/account/api-keys", + color: "#6720ff", + }, + { + id: "cerebras", + name: "Cerebras", + baseUrl: "https://api.cerebras.ai/v1", + group: "cloud", + keyUrl: "https://cloud.cerebras.ai", + color: "#f15a29", + }, + { + id: "huggingface", + name: "Hugging Face", + baseUrl: "https://router.huggingface.co/v1", + group: "cloud", + keyUrl: "https://huggingface.co/settings/tokens", + color: "#ff9d00", + }, + { + id: "scaleway", + name: "Scaleway", + baseUrl: "https://api.scaleway.ai/v1", + group: "cloud", + region: "EU", + keyUrl: "https://console.scaleway.com/iam/api-keys", + color: "#510d8b", + }, + { + id: "azure", name: "Azure OpenAI", baseUrl: "https://YOUR-RESOURCE.openai.azure.com/openai/v1", + group: "cloud", note: "replace YOUR-RESOURCE", + keyUrl: "https://portal.azure.com", + color: "#0078d4", + }, + { + id: "ollama", + name: "Ollama", + baseUrl: "http://localhost:11434/v1", + group: "local", + note: "no key", + noKey: true, + color: "#3b4250", + corsHint: "Start Ollama with OLLAMA_ORIGINS set to this site's address (or *), so the browser may call it.", + }, + { + id: "lmstudio", + name: "LM Studio", + baseUrl: "http://localhost:1234/v1", + group: "local", + note: "no key", + noKey: true, + color: "#4338ca", + corsHint: "Turn on “Enable CORS” in the server settings of LM Studio, so the browser may call it.", + }, + { + id: CUSTOM_PROVIDER_ID, + name: "Custom (OpenAI-compatible)", + baseUrl: "", + group: "other", + note: "any other URL", + color: "#808893", }, - { name: "Ollama", baseUrl: "http://localhost:11434/v1", note: "on your computer, no key" }, - { name: "LM Studio", baseUrl: "http://localhost:1234/v1", note: "on your computer, no key" }, ]; +export const providerGroupLabels: Record = { + popular: "Popular", + cloud: "More cloud services", + local: "On your computer", + other: "Other", +}; + +export function providerById(id: string | undefined): AiProvider | undefined { + return id ? aiProviders.find((provider) => provider.id === id) : undefined; +} + /** The provider of a base URL, by host */ export function providerOf(baseUrl: string): AiProvider | undefined { const host = (url: string) => { @@ -44,6 +216,13 @@ export function providerOf(baseUrl: string): AiProvider | undefined { }; const wanted = host(baseUrl); if (!wanted) return undefined; - if (wanted.endsWith(".openai.azure.com")) return aiProviders.find((provider) => provider.name === "Azure OpenAI"); - return aiProviders.find((provider) => host(provider.baseUrl) === wanted); + if (wanted.endsWith(".openai.azure.com")) return providerById("azure"); + return aiProviders.find((provider) => provider.baseUrl && host(provider.baseUrl) === wanted); +} + +/** Model ids that are not chat models (embeddings, speech, images, moderation), hidden from the Model list */ +const NOT_CHAT = /(embed|whisper|tts|dall-e|moderation|transcri|realtime|rerank|speech|audio|image|-ocr|guard)/i; + +export function isChatModel(id: string): boolean { + return !NOT_CHAT.test(id); } diff --git a/packages/yasgui/src/ai/types.ts b/packages/yasgui/src/ai/types.ts index d9b76611..8517488f 100644 --- a/packages/yasgui/src/ai/types.ts +++ b/packages/yasgui/src/ai/types.ts @@ -68,6 +68,8 @@ export interface AiServiceSettings { id: string; /** Name shown in the AI view, e.g. "Claude"; the host of the base URL when empty */ name?: string; + /** Provider picked in the settings (see providers.ts); found from the base URL when missing */ + providerId?: string; /** Empty for the `defaultBaseUrl` of the config */ baseUrl?: string; /** Optional: local models and some proxies need none */ diff --git a/packages/yasgui/src/index.ts b/packages/yasgui/src/index.ts index 9ca5a1dc..4cd875ba 100644 --- a/packages/yasgui/src/index.ts +++ b/packages/yasgui/src/index.ts @@ -520,12 +520,24 @@ export class Yasgui extends EventEmitter { * * @returns tab */ + /** All open tabs, in no particular order */ + public getTabs(): Tab[] { + return Object.values(this._tabs); + } + public addTab( setActive: boolean, partialTabConfig?: Partial, opts: { atIndex?: number; avoidDuplicateTabs?: boolean } = {}, ): Tab { - const tabConfig = merge({}, Tab.getDefaults(this), partialTabConfig); + // The request settings of Settings → General apply to new tabs too; a tab config (e.g. from a share link) wins + const requestSettings = this.persistentConfig.getRequestSettings(); + const tabConfig = merge( + {}, + Tab.getDefaults(this), + requestSettings ? { requestConfig: requestSettings } : {}, + partialTabConfig, + ); if (tabConfig.id && this.getTab(tabConfig.id)) { throw new Error("Duplicate tab ID"); } diff --git a/packages/yasgui/src/queryManagement/QueryBrowser.ts b/packages/yasgui/src/queryManagement/QueryBrowser.ts index c5adbc43..08444017 100644 --- a/packages/yasgui/src/queryManagement/QueryBrowser.ts +++ b/packages/yasgui/src/queryManagement/QueryBrowser.ts @@ -476,16 +476,7 @@ export default class QueryBrowser { addWorkspaceBtn.textContent = "Add workspace"; addWorkspaceBtn.addEventListener("click", () => { this.close(); - const tab = this.yasgui.getTab(); - if (tab && (tab as any).settingsModal) { - const modal = (tab as any).settingsModal; - modal.open(); - // Switch to workspaces tab after a short delay to ensure modal is rendered - setTimeout(() => { - const workspacesButton = modal.modalContent?.querySelector(".modalNavButton:nth-child(3)") as HTMLElement; - if (workspacesButton) workspacesButton.click(); - }, 50); - } + this.yasgui.getTab()?.openSettings("workspaces"); }); const learnMoreLink = document.createElement("a"); diff --git a/packages/yasgui/src/settingsUi.ts b/packages/yasgui/src/settingsUi.ts new file mode 100644 index 00000000..869b392e --- /dev/null +++ b/packages/yasgui/src/settingsUi.ts @@ -0,0 +1,157 @@ +/** + * Building blocks of the settings pages: a group with a small heading and a list of rows, where each row has a + * name and one line about it on the left and its control on the right. + */ + +let controlCount = 0; + +/** A unique id, for labels of controls (each tab has its own settings modal) */ +export function controlId(name: string): string { + return `yasgui-setting-${name}-${++controlCount}`; +} + +/** A group of settings with a heading and an optional line under it; returns the list to add rows to */ +export function settingsGroup(container: HTMLElement, title: string, description?: string): HTMLElement { + const group = document.createElement("section"); + group.className = "settingsGroup"; + const heading = document.createElement("h3"); + heading.className = "settingsGroupTitle"; + heading.textContent = title; + group.appendChild(heading); + if (description) { + const lede = document.createElement("p"); + lede.className = "settingsGroupLede"; + lede.textContent = description; + group.appendChild(lede); + } + const list = document.createElement("div"); + list.className = "settingsList"; + group.appendChild(list); + container.appendChild(group); + return list; +} + +/** + * A row of a settings list. `labelFor` links the name to the control, so clicking the name focuses or toggles it. + * Returns the row; extra content (such as an editor that opens under the row) can be appended to it. + */ +export function settingsRow( + list: HTMLElement, + title: string, + description: string | undefined, + controls: HTMLElement[], + labelFor?: string, +): HTMLElement { + const row = document.createElement("div"); + row.className = "settingsRow"; + const main = document.createElement("div"); + main.className = "settingsRowMain"; + const text = document.createElement("div"); + text.className = "settingsRowText"; + const name = document.createElement(labelFor ? "label" : "div"); + name.className = "settingsRowTitle"; + name.textContent = title; + if (labelFor) (name as HTMLLabelElement).htmlFor = labelFor; + text.appendChild(name); + if (description) { + const line = document.createElement("div"); + line.className = "settingsRowDescription"; + line.textContent = description; + text.appendChild(line); + } + main.appendChild(text); + if (controls.length) { + const box = document.createElement("div"); + box.className = "settingsRowControls"; + box.append(...controls); + main.appendChild(box); + } + row.appendChild(main); + list.appendChild(row); + return row; +} + +/** An on/off switch (a checkbox with the switch role) */ +export function settingsSwitch(id: string, checked: boolean, onChange: (checked: boolean) => void): HTMLElement { + const wrapper = document.createElement("span"); + wrapper.className = "settingsSwitch"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.id = id; + input.setAttribute("role", "switch"); + input.checked = checked; + input.addEventListener("change", () => onChange(input.checked)); + const track = document.createElement("span"); + track.className = "settingsSwitchTrack"; + track.setAttribute("aria-hidden", "true"); + wrapper.append(input, track); + return wrapper; +} + +/** Two or three choices side by side */ +export function settingsSegmented( + label: string, + options: { value: string; label: string }[], + value: string, + onChange: (value: string) => void, +): HTMLElement { + const group = document.createElement("div"); + group.className = "settingsSegmented"; + group.setAttribute("role", "radiogroup"); + group.setAttribute("aria-label", label); + const buttons: HTMLButtonElement[] = []; + const select = (selected: string) => { + for (const button of buttons) { + const on = button.dataset.value === selected; + button.classList.toggle("active", on); + button.setAttribute("aria-checked", String(on)); + button.tabIndex = on ? 0 : -1; + } + }; + for (const option of options) { + const button = document.createElement("button"); + button.type = "button"; + button.setAttribute("role", "radio"); + button.dataset.value = option.value; + button.textContent = option.label; + button.addEventListener("click", () => { + select(option.value); + onChange(option.value); + }); + button.addEventListener("keydown", (event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const index = buttons.indexOf(button) + (event.key === "ArrowRight" ? 1 : -1); + const next = buttons[(index + buttons.length) % buttons.length]; + next.focus(); + next.click(); + }); + buttons.push(button); + group.appendChild(button); + } + select(value); + return group; +} + +/** A select of the settings, with the class of the other settings controls */ +export function settingsSelect( + id: string, + options: { value: string; label: string }[], + value: string | undefined, + onChange: (value: string) => void, + ariaLabel?: string, +): HTMLSelectElement { + const select = document.createElement("select"); + select.className = "settingsSelect"; + select.id = id; + if (ariaLabel) select.setAttribute("aria-label", ariaLabel); + for (const option of options) { + const element = document.createElement("option"); + element.value = option.value; + element.textContent = option.label; + select.appendChild(element); + } + if (value !== undefined && options.some((option) => option.value === value)) select.value = value; + select.addEventListener("change", () => onChange(select.value)); + return select; +} diff --git a/test/unit/ai-assistant-test.ts b/test/unit/ai-assistant-test.ts index efc1dcaf..091848a6 100644 --- a/test/unit/ai-assistant-test.ts +++ b/test/unit/ai-assistant-test.ts @@ -11,6 +11,7 @@ import { resolveConnection, } from "../../packages/yasgui/src/ai/aiSettings.js"; import { describeErrorBody, joinUrl, listModels } from "../../packages/yasgui/src/ai/openaiClient.js"; +import { aiProviders, isChatModel, providerById, providerOf } from "../../packages/yasgui/src/ai/providers.js"; import { defaultAiConfig } from "../../packages/yasgui/src/ai/types.js"; const expect = chai.expect; @@ -276,6 +277,37 @@ SELECT ?copy ?add WHERE { ?copy ex:Create ?add ; ex:label "DELETE me" }`; } }); + it("tells a service the browser can't reach from an error answer, and drops Gemini's models/ prefix", async () => { + const originalFetch = globalThis.fetch; + const connection = { id: "a", kind: "own" as const, label: "", baseUrl: "https://x.test/v1", host: "x.test" }; + try { + globalThis.fetch = (async () => { + throw new TypeError("Failed to fetch"); + }) as typeof fetch; + const blocked = await listModels(connection).catch((e) => e); + expect(blocked).to.include({ name: "AiRequestError", unreachable: true }); + globalThis.fetch = (async () => new Response("{}", { status: 401 })) as typeof fetch; + const refused = await listModels(connection).catch((e) => e); + expect(refused).to.include({ status: 401, unreachable: false }); + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: [{ id: "models/gemini-2.5-flash" }] }), { status: 200 })) as typeof fetch; + expect(await listModels(connection)).to.deep.equal(["gemini-2.5-flash"]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("finds the provider of a base URL and hides models that can't chat", () => { + expect(providerOf("https://api.mistral.ai/v1")?.id).to.equal("mistral"); + expect(providerOf("https://my-res.openai.azure.com/openai/v1")?.id).to.equal("azure"); + expect(providerOf("https://gateway.example.org/v1")).to.equal(undefined); + expect(providerById("ollama")?.noKey).to.equal(true); + expect(new Set(aiProviders.map((provider) => provider.id)).size).to.equal(aiProviders.length); + expect( + ["gpt-5", "text-embedding-3-small", "whisper-1", "mistral-medium-latest"].filter(isChatModel), + ).to.deep.equal(["gpt-5", "mistral-medium-latest"]); + }); + it("explains error answers of the service", () => { expect(describeErrorBody(401, '{"error":{"message":"Incorrect API key"}}')).to.contain("Incorrect API key"); expect(describeErrorBody(401, "{}")).to.contain("API key was refused");