local-ai-chat-frontend 1.0.0 → 1.2.0

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.
package/README.md CHANGED
@@ -40,7 +40,7 @@ Local LLM providers lack easy-to-deploy, cross-platform GUIs. local-ai-chat-fron
40
40
 
41
41
  ### User Experience
42
42
 
43
- - **Client-side storage** — API keys and endpoint configuration are persisted in IndexedDB and never leave your device.
43
+ - **Client-side storage** — Settings and API keys are kept in this browser's IndexedDB (not encrypted). A key is sent only to the endpoint of its own provider; you can turn off sending it per provider, or turn off saving keys entirely on shared devices.
44
44
  - **Markdown rendering** — AI responses are rendered as Markdown via `marked`, sanitized with DOMPurify before display, supporting code blocks, lists, and formatting.
45
45
  - **Performance metrics** — See tokens per second and timestamp for each AI response.
46
46
  - **Conversation minimap** — A compact minimap beside the scrollbar shows all messages color-coded by sender. Click any block to jump to that message instantly.
@@ -53,7 +53,7 @@ Local LLM providers lack easy-to-deploy, cross-platform GUIs. local-ai-chat-fron
53
53
 
54
54
  ### Deployment
55
55
 
56
- - **npx one-liner** — Run instantly from GitHub without cloning: `npx https://github.com/hidao80/local-ai-chat-frontend`.
56
+ - **npx one-liner** — Run instantly from GitHub without cloning: `npx local-ai-chat-frontend`.
57
57
  - **Docker-ready** — Production image serves the static build via nginx; a dev compose file is included for local development.
58
58
 
59
59
  ## Tech Stack
@@ -67,7 +67,7 @@ Local LLM providers lack easy-to-deploy, cross-platform GUIs. local-ai-chat-fron
67
67
  | Markdown | marked + DOMPurify (sanitization) |
68
68
  | Storage | IndexedDB (native) |
69
69
  | i18n | i18next / react-i18next |
70
- | Server | sirv-cli |
70
+ | Server | Node `http` + sirv (with security headers) / nginx (Docker) |
71
71
 
72
72
  ### Key Technologies
73
73
 
@@ -82,7 +82,7 @@ Local LLM providers lack easy-to-deploy, cross-platform GUIs. local-ai-chat-fron
82
82
  ### Run instantly (no install)
83
83
 
84
84
  ```bash
85
- npx github:hidao80/local-ai-chat-frontend
85
+ npx local-ai-chat-frontend
86
86
  ```
87
87
 
88
88
  > `bunx` does not reliably build `devDependencies` from a raw git ref, so `npx` is the recommended way to run this project directly from GitHub. If you use Bun, clone the repo and run `bun install && bun run build && bun start` instead.
@@ -90,8 +90,8 @@ npx github:hidao80/local-ai-chat-frontend
90
90
  ### Custom port / LAN access
91
91
 
92
92
  ```bash
93
- PORT=8080 npx github:hidao80/local-ai-chat-frontend
94
- HOST=0.0.0.0 PORT=3000 npx github:hidao80/local-ai-chat-frontend
93
+ PORT=8080 npx local-ai-chat-frontend
94
+ HOST=0.0.0.0 PORT=3000 npx local-ai-chat-frontend
95
95
  ```
96
96
 
97
97
  ### Run with Docker
@@ -145,7 +145,7 @@ The application supports multiple LLM providers with automatic model discovery:
145
145
 
146
146
  ### CORS Configuration
147
147
 
148
- **GPT4ALL**: Uses Vite proxy in development mode. When running via `npx` or `bun start`, sirv-cli serves with `--cors` enabled so the browser can reach `localhost:4891` directly.
148
+ **GPT4ALL**: GPT4ALL has no CORS support, so the app reaches it through the Vite dev proxy (`bun run dev`). The production server (`npx` / `bun start`) and the Docker image do not proxy GPT4ALL.
149
149
 
150
150
  **Ollama**: Set environment variable before starting:
151
151
  ```bash
@@ -170,7 +170,7 @@ bun run build
170
170
  ### Type Check
171
171
 
172
172
  ```bash
173
- bunx tsc --noEmit
173
+ bunx tsc -b
174
174
  ```
175
175
 
176
176
  ### Project Structure
@@ -178,7 +178,12 @@ bunx tsc --noEmit
178
178
  ```
179
179
  src/
180
180
  ├── components/
181
- │ └── ChatAndSettings.tsx # Main UI components
181
+ │ ├── Chat.tsx # Chat view (send / stream loop)
182
+ │ ├── ChatSidebar.tsx # Session history sidebar
183
+ │ ├── ConfirmModal.tsx # Confirmation dialog
184
+ │ ├── Minimap.tsx # Chat minimap
185
+ │ └── Settings.tsx # Connection / model settings
186
+ ├── lib/ # Non-UI helpers (API calls, storage, rendering) + unit tests
182
187
  ├── locales/
183
188
  │ ├── en.json # English translations
184
189
  │ └── ja.json # Japanese translations
@@ -0,0 +1,8 @@
1
+ {
2
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src *; media-src 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'self'",
3
+ "X-Frame-Options": "DENY",
4
+ "X-Content-Type-Options": "nosniff",
5
+ "Referrer-Policy": "no-referrer",
6
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
7
+ "Cross-Origin-Opener-Policy": "same-origin"
8
+ }
package/bin/start.js CHANGED
@@ -1,25 +1,43 @@
1
1
  #!/usr/bin/env node
2
- import { spawn, spawnSync } from 'child_process';
2
+ import { spawnSync } from 'child_process';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
- import { existsSync } from 'fs';
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { createServer } from 'http';
7
+ import { isIP } from 'net';
8
+ import { createRequire } from 'module';
9
+ import sirv from 'sirv';
6
10
 
7
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
12
  const rootDir = join(__dirname, '..');
9
13
  const distDir = join(rootDir, 'dist');
10
14
 
15
+ // Validate environment variables before use (exit before starting on invalid values)
16
+ const PORT = process.env.PORT || '3000';
17
+ const HOST = process.env.HOST || 'localhost';
18
+ const HOSTNAME = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
19
+
20
+ if (!/^\d{1,5}$/.test(PORT) || Number(PORT) < 1 || Number(PORT) > 65535) {
21
+ console.error(`Invalid PORT: ${JSON.stringify(PORT)} (expected 1-65535)`);
22
+ process.exit(1);
23
+ }
24
+ if (!isIP(HOST) && !HOSTNAME.test(HOST)) {
25
+ console.error(`Invalid HOST: ${JSON.stringify(HOST)} (expected a hostname or IP address)`);
26
+ process.exit(1);
27
+ }
28
+
11
29
  if (!existsSync(join(distDir, 'index.html'))) {
12
30
  console.log(' dist/ not found, building (first run)...');
13
- const build = spawnSync('npx', ['vite', 'build'], { cwd: rootDir, stdio: 'inherit', shell: true });
31
+ // Run the bundled vite package directly with node, without a shell
32
+ const require = createRequire(import.meta.url);
33
+ const viteBin = join(dirname(require.resolve('vite/package.json')), 'bin', 'vite.js');
34
+ const build = spawnSync(process.execPath, [viteBin, 'build'], { cwd: rootDir, stdio: 'inherit' });
14
35
  if (build.status !== 0) {
15
36
  console.error('Build failed.');
16
37
  process.exit(build.status || 1);
17
38
  }
18
39
  }
19
40
 
20
- const PORT = process.env.PORT || '3000';
21
- const HOST = process.env.HOST || 'localhost';
22
-
23
41
  console.log('');
24
42
  console.log(' local-ai-chat-frontend');
25
43
  console.log(' ====================================');
@@ -35,24 +53,33 @@ console.log(' PORT=8080 npx https://github.com/hidao80/local-ai-chat-frontend
35
53
  console.log(' HOST=0.0.0.0 PORT=3000 npx https://github.com/hidao80/local-ai-chat-frontend (LAN access)');
36
54
  console.log('');
37
55
 
38
- const sirv = spawn(
39
- 'npx',
40
- ['sirv-cli', distDir, '--port', PORT, '--host', HOST, '--single', '--cors', '--dev'],
41
- { stdio: 'inherit', shell: true }
56
+ // Add security headers to every response (same values as nginx.conf)
57
+ const securityHeaders = JSON.parse(
58
+ readFileSync(join(__dirname, 'security-headers.json'), 'utf8')
42
59
  );
60
+ // single: unknown paths return index.html because this is an SPA
61
+ const serve = sirv(distDir, { single: true, etag: true });
62
+
63
+ const server = createServer((req, res) => {
64
+ for (const [name, value] of Object.entries(securityHeaders)) {
65
+ res.setHeader(name, value);
66
+ }
67
+ serve(req, res, () => {
68
+ res.statusCode = 404;
69
+ res.end('Not Found');
70
+ });
71
+ });
43
72
 
44
- sirv.on('error', (err) => {
73
+ server.on('error', (err) => {
45
74
  console.error('Failed to start server:', err.message);
46
75
  process.exit(1);
47
76
  });
48
77
 
49
- process.on('SIGINT', () => {
50
- console.log('\n Shutting down...');
51
- sirv.kill('SIGINT');
52
- process.exit(0);
53
- });
78
+ server.listen(Number(PORT), HOST);
54
79
 
55
- process.on('SIGTERM', () => {
56
- sirv.kill('SIGTERM');
57
- process.exit(0);
58
- });
80
+ const shutdown = () => {
81
+ console.log('\n Shutting down...');
82
+ server.close(() => process.exit(0));
83
+ };
84
+ process.on('SIGINT', shutdown);
85
+ process.on('SIGTERM', shutdown);