tinywebui 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
package/README.md CHANGED
@@ -47,7 +47,7 @@ It comes pre-loaded with two TinySuite MCP servers, so a new install can already
47
47
  - **Hybrid local search** (BM25 plus a small on-device embedding model) over documents and past chats.
48
48
  - **Long chats that stay affordable.** Cache-friendly prefixes, one-time compaction of large tool results, and per-round token statistics.
49
49
  - **Automations.** Cron-scheduled prompts that run in a chat of your choice.
50
- - **Private by default.** Local starts sign in with a one-time link from the terminal, network binds need a password, and a team can use an SSO gateway with roles and an admin panel.
50
+ - **Private by default.** The first account becomes the administrator; later local accounts require a single-use invitation. Teams can alternatively put SSO in front.
51
51
  - **Managed from files.** Version-controlled config that hot-reloads, refuses typos and has a fingerprint you can check against a running instance.
52
52
  - **Themes.** Fall Fairy and Cyber Grid are included. See [theme authoring](public/themes/README.md) to write your own.
53
53
 
@@ -59,31 +59,36 @@ Requires **Node.js 22.13** or later. The bundled TinySearch and TinyContext serv
59
59
  npx tinywebui
60
60
  ```
61
61
 
62
- There is no password to set up. The terminal prints a sign-in link (`http://127.0.0.1:7777/#token=…`); open it and the browser stays signed in. Only someone who can read that terminal gets in. Then connect a model. Put an endpoint and key in `tinywebui.config.json` (start from `example.tinywebui.config.json`) or use the Settings panel:
62
+ Open <http://127.0.0.1:7777> and create the first account with a password of at least 10 characters. That account becomes the administrator and can invite others from the Admin panel. Settings then opens on its first step: add a connector (any OpenAI-compatible URL, with an API key if it needs one), then the models people can pick from it. Every model, with its prompt, sampling and caching, belongs to a connector. The same in `tinywebui.config.json` (start from `example.tinywebui.config.json`):
63
63
 
64
64
  ```json
65
65
  {
66
- "baseUrl": "https://openrouter.ai/api/v1",
67
- "apiKey": "sk-or-...",
68
- "model": "deepseek/deepseek-v4.1-flash"
66
+ "connectors": [
67
+ {
68
+ "id": "local",
69
+ "baseUrl": "http://localhost:11434/v1",
70
+ "models": [{ "id": "llama", "label": "Llama 3.2", "model": "llama3.2" }]
71
+ }
72
+ ]
69
73
  }
70
74
  ```
71
75
 
72
- `tinywebui.config.json` holds your API keys (and password hash, if you set one) and is gitignored. Keep it that way. Run `npx tinywebui set-password` to sign in with a password instead of the link; you need one to listen on anything other than loopback.
76
+ A connector that needs a key takes `"apiKey": "${NAME}"`, filled in from the environment, or the key typed into Settings.
73
77
 
74
- `npx tinywebui` uses keyword search until you run `npx tinywebui models pull fast` (about 90 MB) to enable hybrid search.
78
+ `tinywebui.config.json` describes the deployment (endpoints, connectors, models, policy) and holds no secrets. A key is either a `${NAME}` reference, filled in from the environment, or kept in `tinywebui.secrets.json` beside it: what the Settings panel saves, and the generated session-signing secret. A key written straight into the config file moves there the next time the server starts. Accounts, password hashes and conversations live in `tinywebui.db`. TinyWebUI keeps the secrets file and the database readable by their owner only; keep them private and backed up.
75
79
 
76
- From a clone, run `npm install && npm start`. With Docker, create the owner password in the persistent volume before starting the service:
80
+ `npx tinywebui` keeps the config, keys, database and embedding models in one folder per user: `~/Library/Application Support/TinyWebUI` on macOS, `%LOCALAPPDATA%\TinyWebUI` on Windows and `~/.local/share/tinywebui` (or `$XDG_DATA_HOME/tinywebui`) on Linux. Run it in a folder that has its own `tinywebui.config.json`, or pass `--config`, to use that instead. It uses keyword search until you run `npx tinywebui models pull fast` (about 90 MB) to enable hybrid search.
81
+
82
+ From a clone, run `npm install && npm start`. With Docker, start the service and create the administrator with an email address and password in the browser. The email is checked for valid format; TinyWebUI does not send mail or require a mail server for local accounts:
77
83
 
78
84
  ```bash
79
85
  docker compose build
80
- docker compose run --rm -it tinywebui set-password
81
86
  docker compose up -d
82
87
  ```
83
88
 
84
- The Compose example publishes port 7777 on the host's loopback interface. The Docker image also accepts `TINYWEBUI_PASSWORD` for a headless first start; use a password of at least 15 characters and keep the container's data volume. Without credentials, a headless start exits before opening a port. The runtime images contain Node.js but no npm/npx; connect MCP servers over HTTP or include their executables in a custom image.
89
+ The Compose example publishes port 7777 on the host's loopback interface. Keep the data volume: it contains the accounts, conversations and local password hashes. `npm start` and `npx tinywebui` default to multiuser setup. To run a local workspace without login, explicitly set `"authMode": "local"` in `tinywebui.config.json`; this mode binds only to loopback and has full access to settings and data, without account management. The runtime images contain Node.js but no npm/npx; connect MCP servers over HTTP or include their executables in a custom image.
85
90
 
86
- The Compose example now stores config in its named data volume. If you used the previous bind-mounted `tinywebui.config.json`, copy its settings into the volume before starting, then run `set-password` there.
91
+ The former password-only `single` mode now fails startup instead of opening without login. To move such an installation to local accounts, set `authMode` to `multiuser`, restart, and create the administrator in the browser. On that first setup, existing unowned chats, files, artifacts, folders and automations move to the new administrator. Remove any `TINYWEBUI_PASSWORD` environment variable; it no longer selects an auth mode.
87
92
 
88
93
  ## Documentation
89
94
 
@@ -99,7 +104,7 @@ The full reference lives at **[tinysuite.dev/docs/tinywebui](https://tinysuite.d
99
104
  | [Automations](https://tinysuite.dev/docs/tinywebui/#automations) | Scheduled prompts |
100
105
  | [Long chats](https://tinysuite.dev/docs/tinywebui/#long-chats-caching-and-compaction) | Caching and compaction |
101
106
  | [Usage statistics](https://tinysuite.dev/docs/tinywebui/#usage-statistics) | Token use and attribution |
102
- | [Access](https://tinysuite.dev/docs/tinywebui/#access) | Just you, a password, or a team behind SSO |
107
+ | [Access](https://tinysuite.dev/docs/tinywebui/#access) | Local invited users, explicit local mode, or a team behind SSO |
103
108
  | [Deployment as code](https://tinysuite.dev/docs/tinywebui/#deployment-as-code) | JavaScript config, locked settings, roles and features |
104
109
  | [CLI](https://tinysuite.dev/docs/tinywebui/#cli) | Every command |
105
110
  | [Security and limits](https://tinysuite.dev/docs/tinywebui/#security-and-limits) | Exposure, MCP trust, what isn't included |
@@ -109,10 +114,12 @@ In this repository: [docs/deploy.md](docs/deploy.md) is a scripted container dep
109
114
  ## Development
110
115
 
111
116
  ```bash
112
- npm test # the whole suite, about 10 seconds
117
+ npm test # type check, then the whole suite, about 10 seconds
113
118
  npm run dev # restart on changes in src/ and bin/
114
119
  ```
115
120
 
121
+ The server is TypeScript that Node runs directly, with no build step, so working from a clone needs Node.js 22.18 or later. The npm package ships JavaScript compiled from it (`npm run build`, which `npm pack` runs) and works from 22.13.
122
+
116
123
  [AGENTS.md](AGENTS.md) maps the codebase and says where new code goes. `npm run eval` runs model-behaviour evals from `evals/tasks/` and makes real API calls, so it costs money.
117
124
 
118
125
  ## License
package/bin/tinywebui.js CHANGED
@@ -1,22 +1,28 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from 'node:fs';
3
- import { resolve, dirname } from 'node:path';
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ import { existsSync, mkdirSync } from 'node:fs';
11
+ import { resolve, dirname, join } from 'node:path';
4
12
  import { pathToFileURL } from 'node:url';
5
-
13
+ import { userDataDir, isInstalled } from '../src/config/data_dir.js';
6
14
  const args = process.argv.slice(2);
7
15
  const flag = (name, fallback) => {
8
- const i = args.indexOf(`--${name}`);
9
- return i === -1 ? fallback : args[i + 1];
16
+ const i = args.indexOf(`--${name}`);
17
+ return i === -1 ? fallback : args[i + 1];
10
18
  };
11
-
12
19
  // `--config <path>` (or `validate <path>`) points every command at a config
13
20
  // file, JS or JSON, exactly like $TINYWEBUI_CONFIG does.
14
21
  const configArg = flag('config') ?? (args[0] === 'validate' && args[1] && !args[1].startsWith('--') ? args[1] : undefined);
15
- if (configArg) process.env.TINYWEBUI_CONFIG = resolve(configArg);
16
-
22
+ if (configArg)
23
+ process.env.TINYWEBUI_CONFIG = resolve(configArg);
17
24
  if (args.includes('--help') || args.includes('-h')) {
18
- console.log(`tinywebui [start] [--port 7777] [--host 127.0.0.1] [--config path]
19
- tinywebui set-password create or change the owner password
25
+ console.log(`tinywebui [start] [--port 7777] [--host 127.0.0.1] [--config path]
20
26
  tinywebui validate [path] check the config files; prints the fingerprint, exits 1 on problems
21
27
  tinywebui migrate [--check]
22
28
  bring the database to the current schema (--check: exit 1 if behind)
@@ -36,7 +42,10 @@ tinywebui schema JSON Schema for the config file
36
42
 
37
43
  Config comes from ./tinywebui.config.js if it exists, then
38
44
  ./tinywebui.config.json ($TINYWEBUI_CONFIG may point at either), and MCP
39
- servers from mcp.json next to it (or $TINYWEBUI_MCP).
45
+ servers from mcp.json next to it (or $TINYWEBUI_MCP). Installed from npm
46
+ (npx, npm install -g) and run where neither file is, it uses the per-user
47
+ folder instead: ${userDataDir()}
48
+ The keys, database and embedding models live beside the config.
40
49
 
41
50
  tinywebui.config.js exports the options start() takes, as an object or an
42
51
  (async) function returning one:
@@ -49,56 +58,63 @@ tinywebui.config.js exports the options start() takes, as an object or an
49
58
  Keys under "config" win over the JSON file and are locked in the settings
50
59
  panel. See README for the rest.
51
60
  `);
52
- process.exit(0);
61
+ process.exit(0);
53
62
  }
54
-
55
63
  // Resolve the JS config before src/config/config.js is imported: that module fixes
56
64
  // its JSON path at load time from $TINYWEBUI_CONFIG, so a .js path there has
57
65
  // to be swapped for the JSON file beside it first.
58
66
  const envPath = process.env.TINYWEBUI_CONFIG && resolve(process.env.TINYWEBUI_CONFIG);
59
67
  let jsPath = null;
60
68
  if (envPath && /\.[cm]?js$/.test(envPath)) {
61
- jsPath = envPath;
62
- process.env.TINYWEBUI_CONFIG = resolve(dirname(envPath), 'tinywebui.config.json');
63
- } else if (!envPath) {
64
- for (const name of ['tinywebui.config.js', 'tinywebui.config.mjs']) {
65
- const p = resolve(process.cwd(), name);
66
- if (existsSync(p)) { jsPath = p; break; }
67
- }
69
+ jsPath = envPath;
70
+ process.env.TINYWEBUI_CONFIG = resolve(dirname(envPath), 'tinywebui.config.json');
71
+ }
72
+ else if (!envPath) {
73
+ for (const name of ['tinywebui.config.js', 'tinywebui.config.mjs']) {
74
+ const p = resolve(process.cwd(), name);
75
+ if (existsSync(p)) {
76
+ jsPath = p;
77
+ break;
78
+ }
79
+ }
80
+ // Installed (npx, npm install -g) and not run in a folder with a config of
81
+ // its own: the per-user data folder, so keys and chats do not end up in
82
+ // whichever folder the command happened to run in.
83
+ if (!jsPath && !existsSync(resolve(process.cwd(), 'tinywebui.config.json')) && isInstalled(import.meta.url)) {
84
+ const dir = userDataDir();
85
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
86
+ process.env.TINYWEBUI_CONFIG = join(dir, 'tinywebui.config.json');
87
+ }
68
88
  }
69
-
70
89
  try {
71
- let opts = {};
72
- if (jsPath && !existsSync(jsPath)) throw new Error(`config file not found: ${jsPath}`);
73
- if (jsPath) {
74
- const mod = await import(pathToFileURL(jsPath).href);
75
- const exported = mod.default ?? mod;
76
- opts = (typeof exported === 'function' ? await exported() : exported) || {};
77
- // stderr, so a command's stdout stays pure JSON for scripts.
78
- console.error(`[tinywebui] options: ${jsPath}`);
79
- }
80
- if (args[0] === 'set-password') {
81
- const { setPassword } = await import('../src/set-password.js');
82
- await setPassword(opts);
83
- process.exit(0);
84
- }
85
- const COMMANDS = ['validate', 'effective', 'fingerprint', 'users', 'schema', 'migrate', 'doctor', 'config', 'models'];
86
- if (COMMANDS.includes(args[0])) {
87
- const { runCli } = await import('../src/cli.js');
88
- process.exit(await runCli(args[0], args.slice(1), opts));
89
- }
90
- if (args[0] && !args[0].startsWith('--') && args[0] !== 'start') {
91
- console.error(`[tinywebui] unknown command "${args[0]}" (see --help)`);
90
+ let opts = {};
91
+ if (jsPath && !existsSync(jsPath))
92
+ throw new Error(`config file not found: ${jsPath}`);
93
+ if (jsPath) {
94
+ const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(jsPath).href));
95
+ const exported = mod.default ?? mod;
96
+ opts = (typeof exported === 'function' ? await exported() : exported) || {};
97
+ // stderr, so a command's stdout stays pure JSON for scripts.
98
+ console.error(`[tinywebui] options: ${jsPath}`);
99
+ }
100
+ const COMMANDS = ['validate', 'effective', 'fingerprint', 'users', 'schema', 'migrate', 'doctor', 'config', 'models'];
101
+ if (COMMANDS.includes(args[0])) {
102
+ const { runCli } = await import('../src/cli.js');
103
+ process.exit(await runCli(args[0], args.slice(1), opts));
104
+ }
105
+ if (args[0] && !args[0].startsWith('--') && args[0] !== 'start') {
106
+ console.error(`[tinywebui] unknown command "${args[0]}" (see --help)`);
107
+ process.exit(1);
108
+ }
109
+ const { start } = await import('../src/server.js');
110
+ // Command-line flags beat the file; the file beats the built-in defaults.
111
+ await start({
112
+ ...opts,
113
+ port: Number(flag('port', opts.port ?? process.env.PORT ?? 7777)),
114
+ host: flag('host', opts.host ?? '127.0.0.1'),
115
+ });
116
+ }
117
+ catch (err) {
118
+ console.error(`[tinywebui] ${err.message}`);
92
119
  process.exit(1);
93
- }
94
- const { start } = await import('../src/server.js');
95
- // Command-line flags beat the file; the file beats the built-in defaults.
96
- await start({
97
- ...opts,
98
- port: Number(flag('port', opts.port ?? process.env.PORT ?? 7777)),
99
- host: flag('host', opts.host ?? '127.0.0.1'),
100
- });
101
- } catch (err) {
102
- console.error(`[tinywebui] ${err.message}`);
103
- process.exit(1);
104
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinywebui",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A small, local-first chat workspace for any OpenAI-compatible model, with MCP tools.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -18,18 +18,30 @@
18
18
  "bin",
19
19
  "src",
20
20
  "public",
21
- "README.md"
21
+ "README.md",
22
+ "!**/*.ts"
22
23
  ],
23
24
  "engines": {
24
25
  "node": ">=22.13"
25
26
  },
27
+ "devEngines": {
28
+ "runtime": {
29
+ "name": "node",
30
+ "version": ">=22.18",
31
+ "onFail": "warn"
32
+ }
33
+ },
26
34
  "scripts": {
27
- "prestart": "node bin/tinywebui.js models ensure",
28
- "start": "node bin/tinywebui.js",
29
- "predev": "node bin/tinywebui.js models ensure",
30
- "dev": "node --watch-path=./src --watch-path=./bin bin/tinywebui.js",
31
- "stop": "pkill -f 'bin/tinywebui.js' || true",
32
- "test": "node --test --test-timeout=30000",
35
+ "prestart": "node bin/tinywebui.ts models ensure",
36
+ "start": "node bin/tinywebui.ts",
37
+ "predev": "node bin/tinywebui.ts models ensure",
38
+ "dev": "node --watch-path=./src --watch-path=./bin bin/tinywebui.ts",
39
+ "stop": "pkill -f 'bin/tinywebui' || true",
40
+ "typecheck": "tsc -p .",
41
+ "test": "tsc -p . && node --test --test-timeout=30000",
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "clean": "tsc -b --clean tsconfig.build.json",
44
+ "prepack": "npm run build",
33
45
  "eval": "node evals/run.js"
34
46
  },
35
47
  "dependencies": {
@@ -45,5 +57,10 @@
45
57
  "optionalDependencies": {
46
58
  "@huggingface/tokenizers": "^0.2.0",
47
59
  "onnxruntime-node": "^1.30.0"
60
+ },
61
+ "devDependencies": {
62
+ "@types/heic-convert": "^2.1.1",
63
+ "@types/node": "^22.20.5",
64
+ "typescript": "^7.0.2"
48
65
  }
49
66
  }
package/public/app.js CHANGED
@@ -11,55 +11,53 @@ import { FEATURE } from './shared/features.js';
11
11
  import { initChat, newChat, openChat, sendHeld } from './chat/chat.js';
12
12
  import { initTranscript } from './chat/transcript.js';
13
13
  import { initQueue } from './chat/queue.js';
14
- import { initSidebar, loadChats } from './chat/sidebar.js';
14
+ import { initSidebar, loadChats, closeSideDrawer } from './chat/sidebar.js';
15
15
  import { initAttachments } from './chat/attachments.js';
16
16
  import { initLibrary } from './chat/library.js';
17
17
  import { initComposer } from './chat/composer.js';
18
- import { PANEL, initPanels, openPanel } from './panels/panels.js';
18
+ import { PANEL, initPanels, openPanel, closeAllPanels } from './panels/panels.js';
19
19
  import {
20
- initSettings, onSettingsOpen, onSettingsClose,
21
- loadConfig, loadMcp, loadTools, isLocked, isReadOnly, configuredModel
20
+ initSettings, onSettingsOpen, onSettingsClose, settingsBack, settingsCanClose,
21
+ loadConfig, loadMcp, loadTools, isLocked, isReadOnly, configuredModel, needsSetup
22
22
  } from './panels/settings.js';
23
23
  import { initAutomations, loadAutomations } from './panels/automations.js';
24
24
  import { loadUsage } from './panels/usage.js';
25
25
  import { initAdmin, loadAdmin } from './panels/admin.js';
26
26
 
27
- /**
28
- * 'single' mode with no session: trade a launch link's token for a session,
29
- * or show the sign-in screen and stop there.
30
- */
27
+ /** Local auth with no session: sign in, claim a fresh install, or accept an invite. */
31
28
  async function needsLogin() {
32
- const token = new URLSearchParams(location.hash.slice(1)).get('token');
33
- // Off the address bar first, so it isn't bookmarked or shared by accident.
34
- if (token) history.replaceState(null, '', location.pathname + location.search);
35
29
  const me = await loadAccess();
36
- if (me.authMode !== 'single' || me.user) return false;
37
- if (token && me.login === 'link') {
38
- try {
39
- await api.post('/api/auth/login', { token });
40
- location.reload();
41
- return true;
42
- } catch { /* stale link: fall through to the sign-in screen */ }
43
- }
30
+ if (me.authMode !== 'multiuser' || me.user) return false;
31
+ const invite = new URLSearchParams(location.search).get('invite');
32
+ if (invite) history.replaceState(null, '', location.pathname);
33
+ const setup = me.needsSetup && !invite;
34
+ const joining = Boolean(invite);
44
35
  const box = $('login');
45
36
  box.hidden = false;
46
- if (me.login === 'link') {
47
- $('loginForm').hidden = true;
48
- $('loginLink').hidden = false;
49
- // The link pasted into this same tab only changes the hash: start over.
50
- addEventListener('hashchange', () => location.reload());
51
- return true;
52
- }
53
- $('loginPassword').focus();
37
+ $('loginTitle').textContent = setup ? 'Create the administrator' : joining ? 'Join TinyWebUI' : 'TinyWebUI';
38
+ $('loginHelp').textContent = setup ? 'The first account owns this installation and can invite others.'
39
+ : joining ? 'Create your account using this invitation.' : '';
40
+ for (const id of ['loginEmailLabel', 'loginEmail']) $(id).hidden = false;
41
+ $('loginEmail').required = true;
42
+ $('loginPassword').autocomplete = setup || joining ? 'new-password' : 'current-password';
43
+ $('loginSubmit').textContent = setup ? 'Create administrator' : joining ? 'Create account' : 'Sign in';
44
+ $('loginEmail').focus();
54
45
  $('loginForm').onsubmit = async (e) => {
55
46
  e.preventDefault();
56
47
  $('loginMsg').textContent = '';
57
48
  try {
58
- await api.post('/api/auth/login', { password: $('loginPassword').value });
59
- return location.reload();
49
+ const account = {
50
+ email: $('loginEmail').value,
51
+ password: $('loginPassword').value
52
+ };
53
+ await api.post(setup ? '/api/auth/setup' : joining ? '/api/auth/accept-invite' : '/api/auth/login',
54
+ setup || joining ? { ...account, ...(joining ? { token: invite } : {}) }
55
+ : { email: account.email, password: account.password });
56
+ if (joining) location.href = location.pathname;
57
+ else location.reload();
60
58
  } catch (err) {
61
59
  $('loginMsg').textContent = err.message;
62
- $('loginPassword').select();
60
+ if (!setup && !joining) $('loginPassword').select();
63
61
  }
64
62
  };
65
63
  return true;
@@ -68,7 +66,12 @@ async function needsLogin() {
68
66
  function wire() {
69
67
  initTranscript({ openChat });
70
68
  initQueue({ sendHeld });
71
- initSidebar({ openChat, newChat });
69
+ // Picking a chat or New from the sidebar is a way out of any open panel,
70
+ // unless it holds unsaved edits the person chose to keep.
71
+ initSidebar({
72
+ openChat: (id) => closeAllPanels() && openChat(id),
73
+ newChat: () => closeAllPanels() && newChat()
74
+ });
72
75
  initAttachments();
73
76
  initLibrary();
74
77
  initChat();
@@ -79,21 +82,24 @@ function wire() {
79
82
  initSettings();
80
83
  initAutomations();
81
84
  initPanels({
82
- [PANEL.SETTINGS]: { onOpen: onSettingsOpen, onClose: onSettingsClose, closeId: 'cancel' },
85
+ [PANEL.SETTINGS]: { onOpen: onSettingsOpen, onClose: onSettingsClose, onEscape: settingsBack, canClose: settingsCanClose, closeId: 'cancel' },
83
86
  // The raw editor and the tool list are reloaded on open, so they reflect
84
87
  // any toggles made from the composer menu.
85
88
  [PANEL.MCP]: { onOpen: () => Promise.all([loadMcp(), loadTools()]) },
86
89
  [PANEL.AUTOMATIONS]: { onOpen: loadAutomations },
87
90
  [PANEL.STATISTICS]: { onOpen: loadUsage },
88
91
  [PANEL.ADMIN]: { onOpen: loadAdmin }
89
- });
92
+ }, { onAnyOpen: closeSideDrawer });
90
93
  }
91
94
 
92
95
  if (!(await needsLogin())) {
93
96
  wire();
94
97
  newChat();
95
98
  // Tools render read-only for non-admins, so they wait on the config.
96
- loadConfig().then(() => can(FEATURE.TOOLS) && loadTools());
99
+ loadConfig().then(() => {
100
+ if (needsSetup()) openPanel(PANEL.SETTINGS);
101
+ return can(FEATURE.TOOLS) && loadTools();
102
+ });
97
103
  if (can(FEATURE.MCP)) loadMcp();
98
104
  initAdmin();
99
105
  loadChats();
@@ -210,7 +210,7 @@ function renderTools(body, data) {
210
210
  async function buildModelMenu(pop) {
211
211
  // Tier 3: the pill is a personal choice among your role's models, saved
212
212
  // per user; it never changes anyone else's model.
213
- const personal = whoami().authMode === 'trusted-header';
213
+ const personal = ['multiuser', 'trusted-header'].includes(whoami().authMode);
214
214
  const current = personal ? whoami().model : config.model();
215
215
  if (!personal && config.isLocked('model')) {
216
216
  pop.appendChild(Object.assign(el('div', 'pop-note'), {
@@ -299,8 +299,9 @@ export function clearSearch() {
299
299
  * thing that reads as broken on a phone even when it technically isn't.
300
300
  */
301
301
  /**
302
- * Desktop hides the sidebar outright instead of opening it as a drawer; the
303
- * same #menu button brings it back, so it means "show sidebar" at any width.
302
+ * Desktop hides the sidebar outright instead of opening it as a drawer. The
303
+ * same #menu button does both, and it is on screen at every width -- over the
304
+ * panels too -- so getting back to the chats is always one press away.
304
305
  */
305
306
  const COLLAPSE_KEY = 'tinywebui.side.collapsed';
306
307
  const narrow = () => matchMedia('(max-width: 720px)').matches;
@@ -321,7 +322,7 @@ function setSideDrawer(open) {
321
322
  side.inert = mobile && !open;
322
323
  main.inert = mobile && open;
323
324
  side.toggleAttribute('aria-hidden', mobile && !open);
324
- menu.setAttribute('aria-expanded', String(mobile && open));
325
+ menu.setAttribute('aria-expanded', String(mobile ? open : !document.body.classList.contains('side-collapsed')));
325
326
 
326
327
  if (mobile && open) requestAnimationFrame(() => $('new').focus());
327
328
  if (mobile && !open && side.contains(document.activeElement)) menu.focus();
@@ -330,6 +331,7 @@ export const closeSideDrawer = () => setSideDrawer(false);
330
331
 
331
332
  function setCollapsed(on) {
332
333
  document.body.classList.toggle('side-collapsed', on);
334
+ $('menu').setAttribute('aria-expanded', String(!on));
333
335
  try { on ? localStorage.setItem(COLLAPSE_KEY, '1') : localStorage.removeItem(COLLAPSE_KEY); } catch { /* per-viewer nicety only */ }
334
336
  }
335
337
 
@@ -421,8 +423,9 @@ export function initSidebar(deps) {
421
423
  });
422
424
 
423
425
  try { if (localStorage.getItem(COLLAPSE_KEY)) document.body.classList.add('side-collapsed'); } catch { /* as above */ }
424
- $('sideCollapse').onclick = () => (narrow() ? closeSideDrawer() : setCollapsed(true));
425
- $('menu').onclick = () => (narrow() ? setSideDrawer(!$('side').classList.contains('open')) : setCollapsed(false));
426
+ $('menu').onclick = () => (narrow()
427
+ ? setSideDrawer(!$('side').classList.contains('open'))
428
+ : setCollapsed(!document.body.classList.contains('side-collapsed')));
426
429
  $('side-backdrop').onclick = closeSideDrawer;
427
430
  document.addEventListener('keydown', (e) => {
428
431
  if (e.key === 'Escape' && $('side').classList.contains('open')) closeSideDrawer();