@mooncompany/uplink-chat 0.5.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/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- package/utils/with-retry.js +105 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moon Company LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# ⬡ Uplink
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/uplink-chat)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
**Local-first AI chat with voice** — talk to any LLM from your own machine.
|
|
8
|
+
|
|
9
|
+
<!-- TODO: Add hero screenshot showing Uplink chat interface -->
|
|
10
|
+
|
|
11
|
+
Uplink is a self-hosted chat interface that runs as a lightweight Node.js server on your computer. Connect it to OpenAI, Anthropic, local models, or any OpenAI-compatible API. Add voice with free TTS/STT engines. Access from any device on your network.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx uplink-chat
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That's it. Open `http://localhost:3456` in your browser.
|
|
20
|
+
|
|
21
|
+
## Install Globally
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g uplink-chat
|
|
25
|
+
uplink-chat start
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Any LLM** — OpenAI, Anthropic, Ollama, LM Studio, or any OpenAI-compatible endpoint
|
|
31
|
+
- **Voice Chat** — Text-to-speech and speech-to-text with multiple engine options
|
|
32
|
+
- **Agent Management** — Create, edit, delete, and route between OpenClaw agents directly from Uplink
|
|
33
|
+
- **Local-First** — Runs on your machine. Your data stays on your machine.
|
|
34
|
+
- **PWA** — Install as an app on desktop or mobile from your browser
|
|
35
|
+
- **Themes** — 6 built-in themes (dark, light, OLED, and more)
|
|
36
|
+
- **Mobile Ready** — Responsive UI, works great on phones via local network
|
|
37
|
+
- **Satellites** — Connect multiple AI providers and switch between them
|
|
38
|
+
- **File Uploads** — Attach images (JPEG, PNG, WebP, GIF), audio, video, and documents (PDF, DOCX, Excel) to chat messages
|
|
39
|
+
- **Dashboard** — At-a-glance view of your conversations, agents, and provider status
|
|
40
|
+
- **Split View** — View multiple conversations or panels side by side
|
|
41
|
+
- **Keyboard Shortcuts** — Navigate and control the app without touching the mouse
|
|
42
|
+
- **Encrypted Storage** — Optional password protection for your chat history
|
|
43
|
+
- **Structured Logging** — LOG_LEVEL environment variable support (debug, info, warn, error, silent)
|
|
44
|
+
|
|
45
|
+
## Voice Engines
|
|
46
|
+
|
|
47
|
+
### Text-to-Speech (TTS)
|
|
48
|
+
|
|
49
|
+
| Engine | Cost | Setup |
|
|
50
|
+
|--------|------|-------|
|
|
51
|
+
| Edge TTS | Free | None — works out of the box |
|
|
52
|
+
| OpenAI TTS | Paid | API key required |
|
|
53
|
+
| Coqui XTTS | Free | Local GPU server |
|
|
54
|
+
| ElevenLabs | Paid (~$5/mo) | API key required |
|
|
55
|
+
|
|
56
|
+
### Speech-to-Text (STT)
|
|
57
|
+
|
|
58
|
+
| Engine | Cost | Setup |
|
|
59
|
+
|--------|------|-------|
|
|
60
|
+
| Browser STT | Free | None — built into browser |
|
|
61
|
+
| Faster-Whisper | Free | Local server (pip or Docker) |
|
|
62
|
+
| Groq Whisper | Free tier | API key required |
|
|
63
|
+
| OpenAI Whisper | Paid (~$0.006/min) | API key required |
|
|
64
|
+
|
|
65
|
+
## Logging
|
|
66
|
+
|
|
67
|
+
Control verbosity with the `LOG_LEVEL` environment variable:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
LOG_LEVEL=debug uplink-chat # Verbose debugging output
|
|
71
|
+
LOG_LEVEL=info uplink-chat # Default (application events)
|
|
72
|
+
LOG_LEVEL=warn uplink-chat # Warnings and errors only
|
|
73
|
+
LOG_LEVEL=error uplink-chat # Errors only
|
|
74
|
+
LOG_LEVEL=silent uplink-chat # Suppress all logs
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Default is `info`. Useful for troubleshooting or reducing console noise in production.
|
|
78
|
+
|
|
79
|
+
## CLI
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uplink-chat # Start server (foreground)
|
|
83
|
+
uplink-chat start # Start server (foreground)
|
|
84
|
+
uplink-chat start -d # Start in background
|
|
85
|
+
uplink-chat stop # Stop background server
|
|
86
|
+
uplink-chat status # Check if running
|
|
87
|
+
uplink-chat --port 8080 # Custom port
|
|
88
|
+
uplink-chat --version # Show version
|
|
89
|
+
uplink-chat --help # Show help
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Remote Access
|
|
93
|
+
|
|
94
|
+
Uplink runs on `localhost` by default. To access from your phone or other devices:
|
|
95
|
+
|
|
96
|
+
- **Same network** — Use your computer's local IP (e.g., `http://192.168.1.100:3456`)
|
|
97
|
+
- **Anywhere** — Use [Tailscale](https://tailscale.com) for a private, encrypted connection (recommended)
|
|
98
|
+
- **Advanced** — Cloudflare Tunnel or your own reverse proxy
|
|
99
|
+
|
|
100
|
+
> **Note:** Microphone access (STT) requires HTTPS or localhost. Tailscale provides this automatically.
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
All settings are configured through the web UI — no config files to edit.
|
|
105
|
+
|
|
106
|
+
On first launch, you'll be guided through:
|
|
107
|
+
1. Naming your assistant
|
|
108
|
+
2. Connecting your AI provider (API key + endpoint)
|
|
109
|
+
3. Optional: voice setup, theme selection, password protection
|
|
110
|
+
|
|
111
|
+
## Requirements
|
|
112
|
+
|
|
113
|
+
- **Node.js 18+**
|
|
114
|
+
- A browser (Chrome/Edge recommended for best voice support)
|
|
115
|
+
- An AI provider API key (OpenAI, Anthropic, etc.) or a local model server
|
|
116
|
+
|
|
117
|
+
## Connecting to OpenClaw
|
|
118
|
+
|
|
119
|
+
Uplink can connect to [OpenClaw](https://openclaw.ai) as a gateway for AI providers. To use this:
|
|
120
|
+
|
|
121
|
+
1. Enter your OpenClaw gateway URL and token in Uplink's settings (or during onboarding)
|
|
122
|
+
2. **Enable the chat completions endpoint** in your OpenClaw config:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"gateway": {
|
|
127
|
+
"http": {
|
|
128
|
+
"endpoints": {
|
|
129
|
+
"chatCompletions": {
|
|
130
|
+
"enabled": true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
> **⚠️ Important:** The `chatCompletions` endpoint is **disabled by default** in OpenClaw. Uplink sends all AI requests through `/v1/chat/completions` — if this endpoint isn't enabled, chat will silently fail or return errors. This is the #1 setup issue when connecting Uplink to OpenClaw.
|
|
139
|
+
|
|
140
|
+
You can enable it via the OpenClaw CLI:
|
|
141
|
+
```bash
|
|
142
|
+
openclaw config set gateway.http.endpoints.chatCompletions.enabled true
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Troubleshooting
|
|
146
|
+
|
|
147
|
+
### sharp (Image Processing)
|
|
148
|
+
|
|
149
|
+
`sharp` is a native dependency used for image processing. On some platforms it may require build tools (Python, C++ compiler) to install. If `npm install` fails due to sharp:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
npm install --ignore-scripts
|
|
153
|
+
npm rebuild sharp
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
On most systems, prebuilt binaries are downloaded automatically and no extra steps are needed.
|
|
157
|
+
|
|
158
|
+
| Problem | Cause | Fix |
|
|
159
|
+
|---------|-------|-----|
|
|
160
|
+
| Chat sends but no AI response | `chatCompletions` endpoint disabled in OpenClaw | Enable it (see above) |
|
|
161
|
+
| 401 errors in console | Auth token mismatch or missing | Check gateway token matches in both Uplink settings and OpenClaw config |
|
|
162
|
+
| WebSocket 1006 error via Tailscale | Uplink/OpenClaw running in WSL while Tailscale runs on Windows | See WSL + Tailscale section below |
|
|
163
|
+
| Mic not working on mobile | Not using HTTPS | Access via Tailscale or localhost (browsers require secure context for mic) |
|
|
164
|
+
| Voice not working | TTS/STT engine not configured | Check voice settings — Edge TTS works out of the box with no setup |
|
|
165
|
+
|
|
166
|
+
### WSL + Tailscale (Windows)
|
|
167
|
+
|
|
168
|
+
If you run Uplink or OpenClaw inside **WSL** (Windows Subsystem for Linux) but Tailscale is installed on **Windows**, WebSocket connections over Tailscale will fail with a **1006 error**. This happens because WSL and Windows have separate network stacks — Tailscale traffic arrives at Windows, but can't reach the WSL instance.
|
|
169
|
+
|
|
170
|
+
**Solutions (pick one):**
|
|
171
|
+
|
|
172
|
+
1. **Install Tailscale inside WSL** (recommended) — Run `tailscale up` inside your Ubuntu/WSL instance so it gets its own Tailscale IP. Connect to that hostname instead.
|
|
173
|
+
|
|
174
|
+
2. **Run Uplink on Windows directly** — Node.js works natively on Windows. This puts Uplink and Tailscale on the same network stack.
|
|
175
|
+
|
|
176
|
+
3. **Port forward from Windows to WSL** — Forward traffic from your Windows Tailscale IP into WSL:
|
|
177
|
+
```powershell
|
|
178
|
+
# Get your WSL IP (run inside WSL: hostname -I)
|
|
179
|
+
netsh interface portproxy add v4tov4 listenport=3456 listenaddress=0.0.0.0 connectport=3456 connectaddress=<WSL_IP>
|
|
180
|
+
```
|
|
181
|
+
> ⚠️ WSL's IP can change on reboot, so you may need to update this.
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT — [Moon Company](https://moonco.pro)
|
package/bin/uplink.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Uplink CLI
|
|
5
|
+
* Usage:
|
|
6
|
+
* uplink-chat → starts the server (default)
|
|
7
|
+
* uplink-chat start → starts the server
|
|
8
|
+
* uplink-chat stop → stops any running Uplink server
|
|
9
|
+
* uplink-chat --port N → use custom port (default: 3456)
|
|
10
|
+
* uplink-chat --help → show help
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn, execSync } from 'child_process';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { dirname, join } from 'path';
|
|
16
|
+
import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
|
|
17
|
+
import { createRequire } from 'module';
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = dirname(__filename);
|
|
21
|
+
const ROOT = join(__dirname, '..');
|
|
22
|
+
const PID_FILE = join(ROOT, '.uplink.pid');
|
|
23
|
+
const WATCHDOG_PID_FILE = join(ROOT, '.uplink-watchdog.pid');
|
|
24
|
+
const WATCHDOG_STATE_FILE = join(ROOT, '.uplink-watchdog.json');
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const command = args[0] || 'start';
|
|
28
|
+
|
|
29
|
+
// Parse flags
|
|
30
|
+
const flags = {};
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
if (args[i] === '--port' && args[i + 1]) {
|
|
33
|
+
flags.port = parseInt(args[i + 1], 10);
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
if (args[i] === '--host') {
|
|
37
|
+
flags.host = args[i + 1] || '0.0.0.0';
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
if (args[i] === '--help' || args[i] === '-h') {
|
|
41
|
+
flags.help = true;
|
|
42
|
+
}
|
|
43
|
+
if (args[i] === '--version' || args[i] === '-v') {
|
|
44
|
+
flags.version = true;
|
|
45
|
+
}
|
|
46
|
+
if (args[i] === '--detach' || args[i] === '-d') {
|
|
47
|
+
flags.detach = true;
|
|
48
|
+
}
|
|
49
|
+
if (args[i] === '--no-watchdog') {
|
|
50
|
+
flags.noWatchdog = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Version
|
|
55
|
+
if (flags.version) {
|
|
56
|
+
const require = createRequire(import.meta.url);
|
|
57
|
+
const pkg = require(join(ROOT, 'package.json'));
|
|
58
|
+
console.log(`uplink-chat v${pkg.version}`);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Help
|
|
63
|
+
if (flags.help) {
|
|
64
|
+
console.log(`
|
|
65
|
+
⬡ Uplink — Local AI Chat
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
uplink-chat [command] [options]
|
|
69
|
+
|
|
70
|
+
Commands:
|
|
71
|
+
start Start the Uplink server (default)
|
|
72
|
+
stop Stop a running Uplink server
|
|
73
|
+
status Check if Uplink is running
|
|
74
|
+
|
|
75
|
+
Options:
|
|
76
|
+
--port N Port to listen on (default: 3456)
|
|
77
|
+
--host H Host to bind to (default: 0.0.0.0)
|
|
78
|
+
--detach, -d Run in background (daemon mode)
|
|
79
|
+
--no-watchdog Run without auto-restart watchdog
|
|
80
|
+
-v Show version
|
|
81
|
+
-h Show this help
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
npx uplink-chat # Start on default port
|
|
85
|
+
npx uplink-chat --port 8080 # Start on port 8080
|
|
86
|
+
npx uplink-chat stop # Stop the server
|
|
87
|
+
`);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isRunning(pid) {
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getPid() {
|
|
101
|
+
if (!existsSync(PID_FILE)) return null;
|
|
102
|
+
const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
103
|
+
if (isNaN(pid) || !isRunning(pid)) {
|
|
104
|
+
// Stale pid file
|
|
105
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return pid;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getWatchdogPid() {
|
|
112
|
+
if (!existsSync(WATCHDOG_PID_FILE)) return null;
|
|
113
|
+
const pid = parseInt(readFileSync(WATCHDOG_PID_FILE, 'utf8').trim(), 10);
|
|
114
|
+
if (isNaN(pid) || !isRunning(pid)) {
|
|
115
|
+
try { unlinkSync(WATCHDOG_PID_FILE); } catch {}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return pid;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getWatchdogState() {
|
|
122
|
+
if (!existsSync(WATCHDOG_STATE_FILE)) return null;
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(readFileSync(WATCHDOG_STATE_FILE, 'utf8'));
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatUptime(ms) {
|
|
131
|
+
const seconds = Math.floor(ms / 1000);
|
|
132
|
+
const minutes = Math.floor(seconds / 60);
|
|
133
|
+
const hours = Math.floor(minutes / 60);
|
|
134
|
+
const days = Math.floor(hours / 24);
|
|
135
|
+
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
|
136
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
|
137
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
138
|
+
return `${seconds}s`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── STOP ──
|
|
142
|
+
if (command === 'stop') {
|
|
143
|
+
// Try watchdog first (daemon mode), then direct PID
|
|
144
|
+
const watchdogPid = getWatchdogPid();
|
|
145
|
+
if (watchdogPid) {
|
|
146
|
+
try {
|
|
147
|
+
process.kill(watchdogPid, 'SIGTERM');
|
|
148
|
+
console.log(`⬡ Uplink watchdog stopped (PID ${watchdogPid}).`);
|
|
149
|
+
try { unlinkSync(WATCHDOG_PID_FILE); } catch {}
|
|
150
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error(`⬡ Failed to stop Uplink watchdog (PID ${watchdogPid}):`, e.message);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const pid = getPid();
|
|
159
|
+
if (!pid) {
|
|
160
|
+
console.log('⬡ Uplink is not running.');
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
process.kill(pid, 'SIGTERM');
|
|
165
|
+
console.log(`⬡ Uplink stopped (PID ${pid}).`);
|
|
166
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.error(`⬡ Failed to stop Uplink (PID ${pid}):`, e.message);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── STATUS ──
|
|
175
|
+
if (command === 'status') {
|
|
176
|
+
const watchdogPid = getWatchdogPid();
|
|
177
|
+
const state = getWatchdogState();
|
|
178
|
+
|
|
179
|
+
if (watchdogPid && state) {
|
|
180
|
+
const uptime = formatUptime(Date.now() - state.startedAt);
|
|
181
|
+
console.log(`⬡ Uplink is running (watchdog mode)`);
|
|
182
|
+
console.log(` Watchdog PID: ${watchdogPid}`);
|
|
183
|
+
if (state.serverPid) console.log(` Server PID: ${state.serverPid}`);
|
|
184
|
+
console.log(` Uptime: ${uptime}`);
|
|
185
|
+
console.log(` Restarts: ${state.restartCount || 0}`);
|
|
186
|
+
if (state.backoffMs > 1000) console.log(` Backoff: ${state.backoffMs}ms`);
|
|
187
|
+
} else {
|
|
188
|
+
const pid = getPid();
|
|
189
|
+
if (pid) {
|
|
190
|
+
console.log(`⬡ Uplink is running (PID ${pid}).`);
|
|
191
|
+
} else {
|
|
192
|
+
console.log('⬡ Uplink is not running.');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── START ──
|
|
199
|
+
if (command === 'start' || !['stop', 'status'].includes(command)) {
|
|
200
|
+
// Check both watchdog and direct PID
|
|
201
|
+
const existingWatchdog = getWatchdogPid();
|
|
202
|
+
const existingPid = getPid();
|
|
203
|
+
if (existingWatchdog || existingPid) {
|
|
204
|
+
const pid = existingWatchdog || existingPid;
|
|
205
|
+
const mode = existingWatchdog ? 'watchdog' : 'direct';
|
|
206
|
+
console.log(`⬡ Uplink is already running (${mode}, PID ${pid}).`);
|
|
207
|
+
console.log(` Stop it first: uplink-chat stop`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const env = { ...process.env };
|
|
212
|
+
if (flags.port) env.PORT = String(flags.port);
|
|
213
|
+
if (flags.host) env.UPLINK_HOST = flags.host;
|
|
214
|
+
|
|
215
|
+
const serverPath = join(ROOT, 'server.js');
|
|
216
|
+
|
|
217
|
+
if (flags.detach) {
|
|
218
|
+
// Daemon mode — spawn watchdog (which spawns and monitors server)
|
|
219
|
+
const watchdogPath = join(ROOT, 'server', 'watchdog.js');
|
|
220
|
+
|
|
221
|
+
// Pass extra env vars as JSON arg to watchdog
|
|
222
|
+
const extraEnv = {};
|
|
223
|
+
if (flags.port) extraEnv.PORT = String(flags.port);
|
|
224
|
+
if (flags.host) extraEnv.UPLINK_HOST = flags.host;
|
|
225
|
+
|
|
226
|
+
const child = spawn(process.execPath, [watchdogPath, JSON.stringify(extraEnv)], {
|
|
227
|
+
cwd: ROOT,
|
|
228
|
+
env,
|
|
229
|
+
detached: true,
|
|
230
|
+
stdio: 'ignore',
|
|
231
|
+
});
|
|
232
|
+
child.unref();
|
|
233
|
+
|
|
234
|
+
const port = flags.port || env.PORT || 3456;
|
|
235
|
+
console.log(`⬡ Uplink started in background with watchdog (PID ${child.pid}).`);
|
|
236
|
+
console.log(` → http://localhost:${port}`);
|
|
237
|
+
console.log(` Auto-restarts on crash. Stop with: uplink-chat stop`);
|
|
238
|
+
process.exit(0);
|
|
239
|
+
} else if (flags.noWatchdog) {
|
|
240
|
+
// Foreground mode without watchdog — raw server, no auto-restart
|
|
241
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
242
|
+
cwd: ROOT,
|
|
243
|
+
env,
|
|
244
|
+
stdio: 'inherit',
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
writeFileSync(PID_FILE, String(child.pid));
|
|
248
|
+
|
|
249
|
+
// Clean up pid file on exit
|
|
250
|
+
const cleanup = () => {
|
|
251
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
252
|
+
};
|
|
253
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
254
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
255
|
+
child.on('exit', (code) => { cleanup(); process.exit(code || 0); });
|
|
256
|
+
} else {
|
|
257
|
+
// Foreground mode with watchdog (default) — auto-restarts on crash
|
|
258
|
+
const watchdogPath = join(ROOT, 'server', 'watchdog.js');
|
|
259
|
+
|
|
260
|
+
const extraEnv = {};
|
|
261
|
+
if (flags.port) extraEnv.PORT = String(flags.port);
|
|
262
|
+
if (flags.host) extraEnv.UPLINK_HOST = flags.host;
|
|
263
|
+
|
|
264
|
+
const child = spawn(process.execPath, [watchdogPath, JSON.stringify(extraEnv)], {
|
|
265
|
+
cwd: ROOT,
|
|
266
|
+
env,
|
|
267
|
+
stdio: 'inherit',
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const port = flags.port || env.PORT || 3456;
|
|
271
|
+
console.log(`⬡ Uplink starting with watchdog (auto-restart enabled)`);
|
|
272
|
+
console.log(` → http://localhost:${port}`);
|
|
273
|
+
console.log(` Use --no-watchdog for raw server mode`);
|
|
274
|
+
|
|
275
|
+
process.on('SIGINT', () => { child.kill('SIGTERM'); });
|
|
276
|
+
process.on('SIGTERM', () => { child.kill('SIGTERM'); });
|
|
277
|
+
child.on('exit', (code) => { process.exit(code || 0); });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Centralized error handler middleware
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structured error logging with timestamp, error type, and message
|
|
5
|
+
* @param {Error} err - The error object
|
|
6
|
+
* @param {string} requestId - Request ID for tracing
|
|
7
|
+
* @param {Object} req - Express request object for additional context
|
|
8
|
+
*/
|
|
9
|
+
function logError(err, requestId, req) {
|
|
10
|
+
const timestamp = new Date().toISOString();
|
|
11
|
+
const errorType = err.constructor?.name || err.name || 'Error';
|
|
12
|
+
|
|
13
|
+
const logEntry = {
|
|
14
|
+
timestamp,
|
|
15
|
+
requestId,
|
|
16
|
+
errorType,
|
|
17
|
+
message: err.message,
|
|
18
|
+
code: err.code || 'UNKNOWN',
|
|
19
|
+
statusCode: err.statusCode || 500,
|
|
20
|
+
isOperational: err.isOperational || false,
|
|
21
|
+
stack: err.stack,
|
|
22
|
+
path: req?.path,
|
|
23
|
+
method: req?.method,
|
|
24
|
+
userAgent: req?.headers?.['user-agent'],
|
|
25
|
+
ip: req?.ip || req?.socket?.remoteAddress
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Output as JSON for structured logging (can be ingested by log aggregators)
|
|
29
|
+
console.error(JSON.stringify(logEntry));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get safe error response for client (never includes stack traces in production)
|
|
34
|
+
* @param {Error} err - The error object
|
|
35
|
+
* @returns {Object} Safe error response object
|
|
36
|
+
*/
|
|
37
|
+
function getSafeErrorResponse(err) {
|
|
38
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
39
|
+
|
|
40
|
+
if (err.isOperational) {
|
|
41
|
+
return {
|
|
42
|
+
error: true,
|
|
43
|
+
message: err.message,
|
|
44
|
+
code: err.code
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Never expose stack traces or internal details to client in production
|
|
49
|
+
return {
|
|
50
|
+
error: true,
|
|
51
|
+
message: isProduction ? 'Internal server error' : err.message,
|
|
52
|
+
code: 'INTERNAL_ERROR',
|
|
53
|
+
...(isProduction ? {} : { stack: err.stack })
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function errorHandler(err, req, res, next) {
|
|
58
|
+
const requestId = req.headers['x-request-id'] || 'unknown';
|
|
59
|
+
|
|
60
|
+
// Structured logging (server-side only, never sent to client)
|
|
61
|
+
logError(err, requestId, req);
|
|
62
|
+
|
|
63
|
+
const statusCode = err.statusCode || 500;
|
|
64
|
+
const response = getSafeErrorResponse(err);
|
|
65
|
+
|
|
66
|
+
return res.status(statusCode).json(response);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = errorHandler;
|
package/package.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mooncompany/uplink-chat",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Local-first AI chat with voice — talk to any LLM from your own machine",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "server.js",
|
|
10
|
+
"bin": {
|
|
11
|
+
"uplink-chat": "bin/uplink.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/",
|
|
15
|
+
"middleware/",
|
|
16
|
+
"public/css/",
|
|
17
|
+
"public/dist/",
|
|
18
|
+
"public/img/",
|
|
19
|
+
"public/js/",
|
|
20
|
+
"public/index.html",
|
|
21
|
+
"public/manifest.json",
|
|
22
|
+
"public/sw.js",
|
|
23
|
+
"public/favicon.svg",
|
|
24
|
+
"public/moon_texture.jpg",
|
|
25
|
+
"public/three.min.js",
|
|
26
|
+
"server/",
|
|
27
|
+
"server.js",
|
|
28
|
+
"utils/",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"start": "node bin/uplink.js start",
|
|
34
|
+
"build": "node scripts/build.js",
|
|
35
|
+
"dev": "esbuild public/js/app.js --bundle --outfile=public/dist/bundle.js --format=iife --platform=browser --watch",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest",
|
|
38
|
+
"lint": "eslint public/js/**/*.js",
|
|
39
|
+
"lint:fix": "eslint public/js/**/*.js --fix",
|
|
40
|
+
"format": "prettier --write public/js/**/*.js",
|
|
41
|
+
"format:check": "prettier --check public/js/**/*.js",
|
|
42
|
+
"prepublishOnly": "node scripts/build.js"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/mooncompany/uplink.git"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"ai",
|
|
50
|
+
"chat",
|
|
51
|
+
"voice",
|
|
52
|
+
"tts",
|
|
53
|
+
"stt",
|
|
54
|
+
"local-first",
|
|
55
|
+
"assistant",
|
|
56
|
+
"openai",
|
|
57
|
+
"llm",
|
|
58
|
+
"openclaw"
|
|
59
|
+
],
|
|
60
|
+
"author": "Moon Company <hello@moonco.pro>",
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/mooncompany/uplink/issues"
|
|
64
|
+
},
|
|
65
|
+
"homepage": "https://uplinkchat.app",
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"cors": "^2.8.6",
|
|
68
|
+
"dotenv": "^17.2.3",
|
|
69
|
+
"exceljs": "^4.4.0",
|
|
70
|
+
"express": "^4.18.2",
|
|
71
|
+
"express-rate-limit": "^8.2.1",
|
|
72
|
+
"file-type": "^21.3.0",
|
|
73
|
+
"mammoth": "^1.11.0",
|
|
74
|
+
"multer": "^2.0.2",
|
|
75
|
+
"pdf-parse": "^2.4.5",
|
|
76
|
+
"proper-lockfile": "^4.1.2",
|
|
77
|
+
"web-push": "^3.6.7",
|
|
78
|
+
"ws": "^8.19.0"
|
|
79
|
+
},
|
|
80
|
+
"optionalDependencies": {
|
|
81
|
+
"canvas": "^3.2.1",
|
|
82
|
+
"sharp": "^0.34.5"
|
|
83
|
+
},
|
|
84
|
+
"devDependencies": {
|
|
85
|
+
"esbuild": "^0.27.3",
|
|
86
|
+
"eslint": "^9.39.2",
|
|
87
|
+
"prettier": "^3.3.0",
|
|
88
|
+
"vitest": "^4.0.18"
|
|
89
|
+
},
|
|
90
|
+
"engines": {
|
|
91
|
+
"node": ">=18.0.0"
|
|
92
|
+
}
|
|
93
|
+
}
|