@rozek/nanoclaw 1.2.17
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/.claude/settings.json +1 -0
- package/.claude/skills/add-compact/SKILL.md +135 -0
- package/.claude/skills/add-discord/SKILL.md +203 -0
- package/.claude/skills/add-gmail/SKILL.md +220 -0
- package/.claude/skills/add-image-vision/SKILL.md +94 -0
- package/.claude/skills/add-ollama-tool/SKILL.md +153 -0
- package/.claude/skills/add-parallel/SKILL.md +290 -0
- package/.claude/skills/add-pdf-reader/SKILL.md +104 -0
- package/.claude/skills/add-reactions/SKILL.md +117 -0
- package/.claude/skills/add-slack/SKILL.md +207 -0
- package/.claude/skills/add-telegram/SKILL.md +222 -0
- package/.claude/skills/add-telegram-swarm/SKILL.md +384 -0
- package/.claude/skills/add-voice-transcription/SKILL.md +148 -0
- package/.claude/skills/add-whatsapp/SKILL.md +372 -0
- package/.claude/skills/convert-to-apple-container/SKILL.md +175 -0
- package/.claude/skills/customize/SKILL.md +110 -0
- package/.claude/skills/debug/SKILL.md +349 -0
- package/.claude/skills/get-qodo-rules/SKILL.md +122 -0
- package/.claude/skills/get-qodo-rules/references/output-format.md +41 -0
- package/.claude/skills/get-qodo-rules/references/pagination.md +33 -0
- package/.claude/skills/get-qodo-rules/references/repository-scope.md +26 -0
- package/.claude/skills/qodo-pr-resolver/SKILL.md +326 -0
- package/.claude/skills/qodo-pr-resolver/resources/providers.md +329 -0
- package/.claude/skills/setup/SKILL.md +218 -0
- package/.claude/skills/update-nanoclaw/SKILL.md +235 -0
- package/.claude/skills/update-skills/SKILL.md +130 -0
- package/.claude/skills/use-local-whisper/SKILL.md +152 -0
- package/.claude/skills/x-integration/SKILL.md +417 -0
- package/.claude/skills/x-integration/agent.ts +243 -0
- package/.claude/skills/x-integration/host.ts +159 -0
- package/.claude/skills/x-integration/lib/browser.ts +148 -0
- package/.claude/skills/x-integration/lib/config.ts +62 -0
- package/.claude/skills/x-integration/scripts/like.ts +56 -0
- package/.claude/skills/x-integration/scripts/post.ts +66 -0
- package/.claude/skills/x-integration/scripts/quote.ts +80 -0
- package/.claude/skills/x-integration/scripts/reply.ts +74 -0
- package/.claude/skills/x-integration/scripts/retweet.ts +62 -0
- package/.claude/skills/x-integration/scripts/setup.ts +87 -0
- package/.env.example +1 -0
- package/.github/CODEOWNERS +10 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- package/.github/workflows/bump-version.yml +32 -0
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/merge-forward-skills.yml +160 -0
- package/.github/workflows/update-tokens.yml +42 -0
- package/.husky/pre-commit +1 -0
- package/.mcp.json +3 -0
- package/.nvmrc +1 -0
- package/.prettierrc +3 -0
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +64 -0
- package/CONTRIBUTING.md +23 -0
- package/CONTRIBUTORS.md +15 -0
- package/LICENSE +21 -0
- package/NanoClaw_with_Web-Support.md +290 -0
- package/README.md +261 -0
- package/README_zh.md +200 -0
- package/assets/nanoclaw-favicon.png +0 -0
- package/assets/nanoclaw-icon.png +0 -0
- package/assets/nanoclaw-logo-dark.png +0 -0
- package/assets/nanoclaw-logo.png +0 -0
- package/assets/nanoclaw-profile.jpeg +0 -0
- package/assets/nanoclaw-sales.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/config-examples/mount-allowlist.json +25 -0
- package/container/Dockerfile +70 -0
- package/container/agent-runner/package-lock.json +1524 -0
- package/container/agent-runner/package.json +21 -0
- package/container/agent-runner/src/index.ts +558 -0
- package/container/agent-runner/src/ipc-mcp-stdio.ts +338 -0
- package/container/agent-runner/tsconfig.json +15 -0
- package/container/build.sh +23 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/capabilities/SKILL.md +100 -0
- package/container/skills/status/SKILL.md +104 -0
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.d.ts.map +1 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/index.js.map +1 -0
- package/dist/channels/registry.d.ts +13 -0
- package/dist/channels/registry.d.ts.map +1 -0
- package/dist/channels/registry.js +11 -0
- package/dist/channels/registry.js.map +1 -0
- package/dist/channels/registry.test.d.ts +2 -0
- package/dist/channels/registry.test.d.ts.map +1 -0
- package/dist/channels/registry.test.js +32 -0
- package/dist/channels/registry.test.js.map +1 -0
- package/dist/channels/web.d.ts +2 -0
- package/dist/channels/web.d.ts.map +1 -0
- package/dist/channels/web.js +1738 -0
- package/dist/channels/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +182 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -0
- package/dist/container-runner.d.ts +44 -0
- package/dist/container-runner.d.ts.map +1 -0
- package/dist/container-runner.js +467 -0
- package/dist/container-runner.js.map +1 -0
- package/dist/container-runner.test.d.ts +2 -0
- package/dist/container-runner.test.d.ts.map +1 -0
- package/dist/container-runner.test.js +150 -0
- package/dist/container-runner.test.js.map +1 -0
- package/dist/container-runtime.d.ts +22 -0
- package/dist/container-runtime.d.ts.map +1 -0
- package/dist/container-runtime.js +96 -0
- package/dist/container-runtime.js.map +1 -0
- package/dist/container-runtime.test.d.ts +2 -0
- package/dist/container-runtime.test.d.ts.map +1 -0
- package/dist/container-runtime.test.js +93 -0
- package/dist/container-runtime.test.js.map +1 -0
- package/dist/credential-proxy.d.ts +21 -0
- package/dist/credential-proxy.d.ts.map +1 -0
- package/dist/credential-proxy.js +95 -0
- package/dist/credential-proxy.js.map +1 -0
- package/dist/credential-proxy.test.d.ts +2 -0
- package/dist/credential-proxy.test.d.ts.map +1 -0
- package/dist/credential-proxy.test.js +134 -0
- package/dist/credential-proxy.test.js.map +1 -0
- package/dist/db.d.ts +115 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +549 -0
- package/dist/db.js.map +1 -0
- package/dist/db.test.d.ts +2 -0
- package/dist/db.test.d.ts.map +1 -0
- package/dist/db.test.js +360 -0
- package/dist/db.test.js.map +1 -0
- package/dist/env.d.ts +8 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +42 -0
- package/dist/env.js.map +1 -0
- package/dist/formatting.test.d.ts +2 -0
- package/dist/formatting.test.d.ts.map +1 -0
- package/dist/formatting.test.js +183 -0
- package/dist/formatting.test.js.map +1 -0
- package/dist/group-folder.d.ts +5 -0
- package/dist/group-folder.d.ts.map +1 -0
- package/dist/group-folder.js +44 -0
- package/dist/group-folder.js.map +1 -0
- package/dist/group-folder.test.d.ts +2 -0
- package/dist/group-folder.test.d.ts.map +1 -0
- package/dist/group-folder.test.js +29 -0
- package/dist/group-folder.test.js.map +1 -0
- package/dist/group-queue.d.ts +34 -0
- package/dist/group-queue.d.ts.map +1 -0
- package/dist/group-queue.js +263 -0
- package/dist/group-queue.js.map +1 -0
- package/dist/group-queue.test.d.ts +2 -0
- package/dist/group-queue.test.d.ts.map +1 -0
- package/dist/group-queue.test.js +341 -0
- package/dist/group-queue.test.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +518 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc-auth.test.d.ts +2 -0
- package/dist/ipc-auth.test.d.ts.map +1 -0
- package/dist/ipc-auth.test.js +434 -0
- package/dist/ipc-auth.test.js.map +1 -0
- package/dist/ipc.d.ts +32 -0
- package/dist/ipc.d.ts.map +1 -0
- package/dist/ipc.js +311 -0
- package/dist/ipc.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/mount-security.d.ts +34 -0
- package/dist/mount-security.d.ts.map +1 -0
- package/dist/mount-security.js +325 -0
- package/dist/mount-security.js.map +1 -0
- package/dist/remote-control.d.ts +32 -0
- package/dist/remote-control.d.ts.map +1 -0
- package/dist/remote-control.js +185 -0
- package/dist/remote-control.js.map +1 -0
- package/dist/remote-control.test.d.ts +2 -0
- package/dist/remote-control.test.d.ts.map +1 -0
- package/dist/remote-control.test.js +321 -0
- package/dist/remote-control.test.js.map +1 -0
- package/dist/router.d.ts +8 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +37 -0
- package/dist/router.js.map +1 -0
- package/dist/routing.test.d.ts +2 -0
- package/dist/routing.test.d.ts.map +1 -0
- package/dist/routing.test.js +81 -0
- package/dist/routing.test.js.map +1 -0
- package/dist/sender-allowlist.d.ts +14 -0
- package/dist/sender-allowlist.d.ts.map +1 -0
- package/dist/sender-allowlist.js +79 -0
- package/dist/sender-allowlist.js.map +1 -0
- package/dist/sender-allowlist.test.d.ts +2 -0
- package/dist/sender-allowlist.test.d.ts.map +1 -0
- package/dist/sender-allowlist.test.js +186 -0
- package/dist/sender-allowlist.test.js.map +1 -0
- package/dist/session-commands.d.ts +47 -0
- package/dist/session-commands.d.ts.map +1 -0
- package/dist/session-commands.js +102 -0
- package/dist/session-commands.js.map +1 -0
- package/dist/session-commands.test.d.ts +2 -0
- package/dist/session-commands.test.d.ts.map +1 -0
- package/dist/session-commands.test.js +190 -0
- package/dist/session-commands.test.js.map +1 -0
- package/dist/task-scheduler.d.ts +22 -0
- package/dist/task-scheduler.d.ts.map +1 -0
- package/dist/task-scheduler.js +210 -0
- package/dist/task-scheduler.js.map +1 -0
- package/dist/task-scheduler.test.d.ts +2 -0
- package/dist/task-scheduler.test.d.ts.map +1 -0
- package/dist/task-scheduler.test.js +107 -0
- package/dist/task-scheduler.test.js.map +1 -0
- package/dist/timezone.d.ts +6 -0
- package/dist/timezone.d.ts.map +1 -0
- package/dist/timezone.js +17 -0
- package/dist/timezone.js.map +1 -0
- package/dist/timezone.test.d.ts +2 -0
- package/dist/timezone.test.d.ts.map +1 -0
- package/dist/timezone.test.js +23 -0
- package/dist/timezone.test.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/APPLE-CONTAINER-NETWORKING.md +90 -0
- package/docs/DEBUG_CHECKLIST.md +143 -0
- package/docs/REQUIREMENTS.md +196 -0
- package/docs/SDK_DEEP_DIVE.md +643 -0
- package/docs/SECURITY.md +122 -0
- package/docs/SPEC.md +785 -0
- package/docs/docker-sandboxes.md +359 -0
- package/docs/nanoclaw-architecture-final.md +1063 -0
- package/docs/nanorepo-architecture.md +168 -0
- package/docs/skills-as-branches.md +662 -0
- package/groups/global/CLAUDE.md +58 -0
- package/groups/main/CLAUDE.md +246 -0
- package/launchd/com.nanoclaw.plist +32 -0
- package/package.json +45 -0
- package/repo-tokens/README.md +113 -0
- package/repo-tokens/action.yml +186 -0
- package/repo-tokens/badge.svg +23 -0
- package/repo-tokens/examples/green.svg +14 -0
- package/repo-tokens/examples/red.svg +14 -0
- package/repo-tokens/examples/yellow-green.svg +14 -0
- package/repo-tokens/examples/yellow.svg +14 -0
- package/scripts/run-migrations.ts +105 -0
- package/setup/container.ts +144 -0
- package/setup/environment.test.ts +121 -0
- package/setup/environment.ts +94 -0
- package/setup/groups.ts +229 -0
- package/setup/index.ts +58 -0
- package/setup/mounts.ts +115 -0
- package/setup/platform.test.ts +120 -0
- package/setup/platform.ts +132 -0
- package/setup/register.test.ts +257 -0
- package/setup/register.ts +177 -0
- package/setup/service.test.ts +187 -0
- package/setup/service.ts +362 -0
- package/setup/status.ts +16 -0
- package/setup/verify.ts +192 -0
- package/setup.sh +161 -0
- package/src/channels/index.ts +12 -0
- package/src/channels/registry.test.ts +42 -0
- package/src/channels/registry.ts +32 -0
- package/src/channels/web.ts +1856 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +73 -0
- package/src/container-runner.test.ts +210 -0
- package/src/container-runner.ts +707 -0
- package/src/container-runtime.test.ts +149 -0
- package/src/container-runtime.ts +127 -0
- package/src/credential-proxy.test.ts +192 -0
- package/src/credential-proxy.ts +125 -0
- package/src/db.test.ts +484 -0
- package/src/db.ts +803 -0
- package/src/env.ts +42 -0
- package/src/formatting.test.ts +256 -0
- package/src/group-folder.test.ts +43 -0
- package/src/group-folder.ts +44 -0
- package/src/group-queue.test.ts +484 -0
- package/src/group-queue.ts +365 -0
- package/src/index.ts +731 -0
- package/src/ipc-auth.test.ts +679 -0
- package/src/ipc.ts +461 -0
- package/src/logger.ts +16 -0
- package/src/mount-security.ts +419 -0
- package/src/remote-control.test.ts +397 -0
- package/src/remote-control.ts +224 -0
- package/src/router.ts +52 -0
- package/src/routing.test.ts +170 -0
- package/src/sender-allowlist.test.ts +216 -0
- package/src/sender-allowlist.ts +128 -0
- package/src/session-commands.test.ts +247 -0
- package/src/session-commands.ts +163 -0
- package/src/task-scheduler.test.ts +129 -0
- package/src/task-scheduler.ts +295 -0
- package/src/timezone.test.ts +29 -0
- package/src/timezone.ts +16 -0
- package/src/types.ts +107 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +7 -0
- package/vitest.skills.config.ts +7 -0
|
@@ -0,0 +1,1856 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
import { logger } from '../logger.js';
|
|
7
|
+
import { NewMessage } from '../types.js';
|
|
8
|
+
import { ChannelOpts, registerChannel } from './registry.js';
|
|
9
|
+
import {
|
|
10
|
+
getAllChats,
|
|
11
|
+
getConversation,
|
|
12
|
+
storeMessage,
|
|
13
|
+
storeChatMetadata,
|
|
14
|
+
updateChatName,
|
|
15
|
+
updateChatCwd,
|
|
16
|
+
deleteChat,
|
|
17
|
+
clearChatMessages,
|
|
18
|
+
deleteMessage,
|
|
19
|
+
getWebSessionOrder,
|
|
20
|
+
setWebSessionOrder,
|
|
21
|
+
} from '../db.js';
|
|
22
|
+
import { DATA_DIR, GROUPS_DIR } from '../config.js';
|
|
23
|
+
|
|
24
|
+
// NANOCLAW_PORT/HOST/TOKEN are the canonical env var names; WEB_CHANNEL_* kept for backward compat.
|
|
25
|
+
const PORT = parseInt(
|
|
26
|
+
process.env.NANOCLAW_PORT || process.env.WEB_CHANNEL_PORT || '3099',
|
|
27
|
+
10,
|
|
28
|
+
);
|
|
29
|
+
const HOST =
|
|
30
|
+
process.env.NANOCLAW_HOST || process.env.WEB_CHANNEL_HOST || '0.0.0.0';
|
|
31
|
+
/** Optional access token for the web interface. Empty string = no protection. */
|
|
32
|
+
const TOKEN = process.env.NANOCLAW_TOKEN || '';
|
|
33
|
+
const WEB_JID_PREFIX = 'local@web-';
|
|
34
|
+
const GROUP_FOLDER = 'main';
|
|
35
|
+
const CRON_GROUP_FOLDER = 'web-cron'; // separate folder so cron container gets its own IPC directory
|
|
36
|
+
const GROUP_NAME = 'Web Chat';
|
|
37
|
+
const CRON_SESSION_ID = 'cron';
|
|
38
|
+
const CRON_SESSION_NAME = 'Cron Jobs';
|
|
39
|
+
const MAX_BODY_SIZE = 1 * 1024 * 1024; // 1 MB — default for all POST bodies
|
|
40
|
+
const MAX_UPLOAD_BODY_SIZE = 10 * 1024 * 1024; // 10 MB — for /upload (Base64 file data)
|
|
41
|
+
|
|
42
|
+
// Per-session SSE clients and ephemeral UI state
|
|
43
|
+
const sseClients = new Map<string, Set<http.ServerResponse>>();
|
|
44
|
+
const sessionCwds = new Map<string, string>();
|
|
45
|
+
const sessionTyping = new Map<string, boolean>(); // true while agent is processing
|
|
46
|
+
const sessionStatus = new Map<string, string>(); // last status SSE payload (or 'null')
|
|
47
|
+
const registeredSessions = new Set<string>();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sanitize a session name:
|
|
51
|
+
* - removes Unicode control characters (Cc: U+0000–U+001F, U+007F–U+009F)
|
|
52
|
+
* - trims leading/trailing whitespace
|
|
53
|
+
* - limits to 256 characters
|
|
54
|
+
* Returns null if the result is empty (name should be rejected).
|
|
55
|
+
*/
|
|
56
|
+
function sanitizeSessionName(raw: string): string | null {
|
|
57
|
+
const cleaned = raw
|
|
58
|
+
.replace(/\p{Cc}/gu, '')
|
|
59
|
+
.trim()
|
|
60
|
+
.slice(0, 256);
|
|
61
|
+
return cleaned || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parse the Cookie request header into a key→value map. */
|
|
65
|
+
function parseCookies(req: http.IncomingMessage): Record<string, string> {
|
|
66
|
+
const header = req.headers.cookie ?? '';
|
|
67
|
+
return Object.fromEntries(
|
|
68
|
+
header.split(';').flatMap((part) => {
|
|
69
|
+
const eq = part.indexOf('=');
|
|
70
|
+
if (eq < 1) return [];
|
|
71
|
+
const k = part.slice(0, eq).trim();
|
|
72
|
+
const v = part.slice(eq + 1).trim();
|
|
73
|
+
try {
|
|
74
|
+
return [[k, decodeURIComponent(v)]];
|
|
75
|
+
} catch {
|
|
76
|
+
return [[k, v]];
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Authorize the request and optionally upgrade to a session cookie.
|
|
84
|
+
*
|
|
85
|
+
* - No TOKEN configured → always returns true (no-op).
|
|
86
|
+
* - Token matches via ?token= query param → sets HttpOnly cookie so subsequent
|
|
87
|
+
* requests (SSE, API calls) are automatically authorized without repeating the token.
|
|
88
|
+
* - Token does not match → sends 401 and returns false.
|
|
89
|
+
*
|
|
90
|
+
* Accepted token locations (checked in order):
|
|
91
|
+
* 1. Cookie nanoclaw_token=<token>
|
|
92
|
+
* 2. Authorization Bearer <token>
|
|
93
|
+
* 3. Query param ?token=<value> (also upgrades to cookie on match)
|
|
94
|
+
*
|
|
95
|
+
* Must be called before response headers are written. If it returns false the
|
|
96
|
+
* response has already been sent — return from the caller immediately.
|
|
97
|
+
*/
|
|
98
|
+
function authorizeRequest(
|
|
99
|
+
req: http.IncomingMessage,
|
|
100
|
+
res: http.ServerResponse,
|
|
101
|
+
): boolean {
|
|
102
|
+
if (!TOKEN) return true;
|
|
103
|
+
const cookies = parseCookies(req);
|
|
104
|
+
if (cookies['nanoclaw_token'] === TOKEN) return true;
|
|
105
|
+
const auth = req.headers['authorization'] ?? '';
|
|
106
|
+
if (auth.startsWith('Bearer ') && auth.slice(7) === TOKEN) return true;
|
|
107
|
+
// Parse ?token= once — used for both the auth check and the cookie upgrade.
|
|
108
|
+
const raw = req.url?.match(/[?&]token=([^&]*)/)?.[1];
|
|
109
|
+
if (raw !== undefined) {
|
|
110
|
+
try {
|
|
111
|
+
if (decodeURIComponent(raw) === TOKEN) {
|
|
112
|
+
res.setHeader(
|
|
113
|
+
'Set-Cookie',
|
|
114
|
+
`nanoclaw_token=${TOKEN}; HttpOnly; SameSite=Strict; Path=/`,
|
|
115
|
+
);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
/* ignore malformed param */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
res.writeHead(401, {
|
|
123
|
+
'Content-Type': 'application/json',
|
|
124
|
+
'WWW-Authenticate': 'Bearer realm="NanoClaw"',
|
|
125
|
+
});
|
|
126
|
+
res.end('{"error":"Unauthorized"}');
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Collect the full POST body, enforcing a size limit.
|
|
132
|
+
* Sends 413 and returns early if the body exceeds maxSize.
|
|
133
|
+
* Calls callback(body) once the full body has been read.
|
|
134
|
+
*
|
|
135
|
+
* @param maxSize - byte limit (defaults to MAX_BODY_SIZE = 1 MB)
|
|
136
|
+
*/
|
|
137
|
+
function collectBody(
|
|
138
|
+
req: http.IncomingMessage,
|
|
139
|
+
res: http.ServerResponse,
|
|
140
|
+
callback: (body: string) => void,
|
|
141
|
+
maxSize: number = MAX_BODY_SIZE,
|
|
142
|
+
): void {
|
|
143
|
+
let body = '';
|
|
144
|
+
let tooLarge = false;
|
|
145
|
+
req.on('data', (chunk) => {
|
|
146
|
+
if (tooLarge) return;
|
|
147
|
+
body += chunk;
|
|
148
|
+
if (body.length > maxSize) {
|
|
149
|
+
tooLarge = true;
|
|
150
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
151
|
+
res.end('{"error":"Request too large"}');
|
|
152
|
+
req.resume(); // drain so the socket can close cleanly
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
req.on('end', () => {
|
|
156
|
+
if (tooLarge) return;
|
|
157
|
+
callback(body);
|
|
158
|
+
});
|
|
159
|
+
req.on('error', () => {
|
|
160
|
+
tooLarge = true; // prevent callback from firing after a request error
|
|
161
|
+
if (!res.headersSent) {
|
|
162
|
+
res.writeHead(400);
|
|
163
|
+
res.end('Bad request');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Set CWD in the in-memory cache AND persist it to the DB. */
|
|
169
|
+
function setCwd(sessionId: string, cwd: string): void {
|
|
170
|
+
sessionCwds.set(sessionId, cwd);
|
|
171
|
+
try {
|
|
172
|
+
updateChatCwd(WEB_JID_PREFIX + sessionId, cwd);
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getOrCreateClientSet(sessionId: string): Set<http.ServerResponse> {
|
|
177
|
+
if (!sseClients.has(sessionId)) sseClients.set(sessionId, new Set());
|
|
178
|
+
return sseClients.get(sessionId)!;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function broadcastToSession(
|
|
182
|
+
sessionId: string,
|
|
183
|
+
event: string,
|
|
184
|
+
data: string,
|
|
185
|
+
): void {
|
|
186
|
+
const clients = sseClients.get(sessionId);
|
|
187
|
+
if (!clients) return;
|
|
188
|
+
const payload = `event: ${event}\ndata: ${data}\n\n`;
|
|
189
|
+
for (const client of clients) {
|
|
190
|
+
try {
|
|
191
|
+
client.write(payload);
|
|
192
|
+
} catch {
|
|
193
|
+
clients.delete(client);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sessionIdFromJid(jid: string): string {
|
|
199
|
+
return jid.startsWith(WEB_JID_PREFIX)
|
|
200
|
+
? jid.slice(WEB_JID_PREFIX.length)
|
|
201
|
+
: jid;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sidFromUrl(url: string | undefined): string {
|
|
205
|
+
const raw = url?.match(/[?&]sid=([^&]+)/)?.[1] ?? 'default';
|
|
206
|
+
// Sanitize: only alphanumeric, hyphens and underscores, max 64 chars
|
|
207
|
+
return raw.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'default';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function getLocalIp(): string {
|
|
211
|
+
for (const ifaces of Object.values(os.networkInterfaces())) {
|
|
212
|
+
for (const iface of ifaces ?? []) {
|
|
213
|
+
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return '127.0.0.1';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const HTML = `<!DOCTYPE html>
|
|
220
|
+
<html lang="en">
|
|
221
|
+
<head>
|
|
222
|
+
<meta charset="UTF-8">
|
|
223
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
224
|
+
<title>NanoClaw</title>
|
|
225
|
+
<link rel="icon" type="image/png" href="/favicon.png">
|
|
226
|
+
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
227
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
228
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
|
229
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
|
230
|
+
<style>
|
|
231
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
232
|
+
html, body { height: 100%; }
|
|
233
|
+
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #1a1a1a; display: flex; flex-direction: column; overflow: hidden; }
|
|
234
|
+
|
|
235
|
+
/* Header */
|
|
236
|
+
#header { background: #fff; padding: 10px 16px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; flex-shrink: 0; }
|
|
237
|
+
#toggle-sidebar { background: none; border: none; cursor: pointer; font-size: 20px; color: #555; padding: 2px 6px; border-radius: 4px; line-height: 1; }
|
|
238
|
+
#toggle-sidebar:hover { background: #f0f0f0; }
|
|
239
|
+
#header-title { flex-shrink: 0; white-space: nowrap; }
|
|
240
|
+
#header-cwd { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #888; font-weight: normal; font-size: 14px; min-width: 0; font-family: monospace; }
|
|
241
|
+
|
|
242
|
+
/* Main area */
|
|
243
|
+
#main-area { display: flex; flex: 1; overflow: hidden; }
|
|
244
|
+
|
|
245
|
+
/* Sidebar */
|
|
246
|
+
#sidebar { width: 220px; min-width: 220px; background: #fff; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; transition: width 0.2s, min-width 0.2s; overflow: hidden; }
|
|
247
|
+
#sidebar.collapsed { width: 0; min-width: 0; }
|
|
248
|
+
#sidebar-header { padding: 10px 12px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
|
249
|
+
#sidebar-title { font-weight: 600; font-size: 13px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
250
|
+
#new-session-btn { background: none; border: 1px solid #d0d0d0; color: #555; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center; line-height: 1; flex-shrink: 0; }
|
|
251
|
+
#new-session-btn:hover { background: #f0f0f0; border-color: #aaa; }
|
|
252
|
+
#session-list { flex: 1; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 2px; }
|
|
253
|
+
.session-item { display: flex; align-items: center; padding: 7px 8px; border-radius: 6px; cursor: pointer; gap: 6px; min-width: 0; }
|
|
254
|
+
.session-item:hover { background: #f5f5f5; }
|
|
255
|
+
.session-item.active { background: #eff6ff; }
|
|
256
|
+
.session-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #1a1a1a; }
|
|
257
|
+
.session-item.active .session-name { color: #2563eb; font-weight: 500; }
|
|
258
|
+
.session-item.unread .session-name { font-weight: 600; }
|
|
259
|
+
.session-unread-dot { width: 7px; height: 7px; border-radius: 50%; background: #2563eb; flex-shrink: 0; display: none; }
|
|
260
|
+
.session-item.unread .session-unread-dot { display: block; }
|
|
261
|
+
.session-name-input { flex: 1; font-size: 13px; border: 1px solid #2563eb; border-radius: 3px; padding: 1px 4px; outline: none; min-width: 0; }
|
|
262
|
+
.session-actions { display: none; align-items: center; gap: 1px; flex-shrink: 0; }
|
|
263
|
+
.session-item:hover .session-actions { display: flex; }
|
|
264
|
+
.session-btn { background: none; border: none; cursor: pointer; color: #999; padding: 2px 3px; border-radius: 3px; font-size: 13px; line-height: 1; }
|
|
265
|
+
.session-btn:hover { background: #e0e0e0; color: #333; }
|
|
266
|
+
|
|
267
|
+
/* Messages */
|
|
268
|
+
#messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
269
|
+
.msg-row { display: flex; align-items: flex-start; gap: 5px; }
|
|
270
|
+
.msg-row.bot { justify-content: flex-start; }
|
|
271
|
+
.msg-row.user { justify-content: flex-end; }
|
|
272
|
+
.msg-row.user .del-btn { order: -1; }
|
|
273
|
+
.del-btn { flex-shrink: 0; background: none; border: none; cursor: pointer; color: #ccc; padding: 2px 3px; line-height: 1; border-radius: 4px; transition: color 0.15s; margin-top: 7px; }
|
|
274
|
+
.del-btn:hover { color: #ef4444; }
|
|
275
|
+
.msg { max-width: 75%; padding: 10px 14px; border-radius: 12px; line-height: 1.6; word-break: break-word; font-size: 15px; }
|
|
276
|
+
.msg.user { background: #2563eb; color: #fff; border-bottom-right-radius: 4px; white-space: pre-wrap; }
|
|
277
|
+
.msg.bot { background: #ffffff; border: 1px solid #e0e0e0; border-bottom-left-radius: 4px; position: relative; }
|
|
278
|
+
.msg.typing { color: #888; font-style: italic; }
|
|
279
|
+
.msg.status { color: #999; font-size: 12px; font-style: italic; background: transparent; border: none; padding: 2px 0; max-width: 100%; }
|
|
280
|
+
.msg.bot p { margin: 0.4em 0; }
|
|
281
|
+
.msg.bot p:first-child { margin-top: 0; }
|
|
282
|
+
.msg.bot p:last-child { margin-bottom: 0; }
|
|
283
|
+
.msg.bot ul, .msg.bot ol { padding-left: 1.5em; margin: 0.4em 0; }
|
|
284
|
+
.msg.bot li { margin: 0.2em 0; }
|
|
285
|
+
.msg.bot h1, .msg.bot h2, .msg.bot h3, .msg.bot h4 { margin: 0.6em 0 0.3em; line-height: 1.3; }
|
|
286
|
+
.msg.bot pre { background: #f6f8fa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 14px; overflow-x: auto; margin: 0.6em 0; white-space: pre; position: relative; }
|
|
287
|
+
.copy-btn { position: absolute; top: 6px; right: 6px; background: none; border: none; border-radius: 4px; padding: 2px 4px; cursor: pointer; color: #bbb; transition: color 0.15s; line-height: 1; }
|
|
288
|
+
.copy-btn:hover { color: #555; }
|
|
289
|
+
.copy-btn.copied { color: #16a34a; }
|
|
290
|
+
.msg.bot code { background: #f0f2f4; padding: 2px 5px; border-radius: 4px; font-size: 0.88em; font-family: monospace; }
|
|
291
|
+
.msg.bot pre code { background: none; padding: 0; font-size: 0.88em; }
|
|
292
|
+
.msg.bot blockquote { border-left: 3px solid #d0d0d0; padding-left: 12px; color: #666; margin: 0.5em 0; }
|
|
293
|
+
.msg.bot table { border-collapse: collapse; margin: 0.6em 0; }
|
|
294
|
+
.msg.bot th, .msg.bot td { border: 1px solid #e0e0e0; padding: 6px 12px; }
|
|
295
|
+
.msg.bot th { background: #f0f2f4; font-weight: 600; }
|
|
296
|
+
.msg.bot a { color: #2563eb; text-decoration: underline; }
|
|
297
|
+
.msg.bot hr { border: none; border-top: 1px solid #e0e0e0; margin: 0.6em 0; }
|
|
298
|
+
|
|
299
|
+
/* Input */
|
|
300
|
+
#input-area { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #e0e0e0; background: #f5f5f5; flex-shrink: 0; }
|
|
301
|
+
#input { flex: 1; background: #fff; border: 1px solid #d0d0d0; color: #1a1a1a; padding: 10px 14px; border-radius: 8px; font-size: 15px; outline: none; resize: none; max-height: 120px; }
|
|
302
|
+
#input:focus { border-color: #2563eb; }
|
|
303
|
+
#send { background: #2563eb; color: #fff; border: none; padding: 10px 18px; border-radius: 8px; cursor: pointer; font-size: 15px; }
|
|
304
|
+
#send:hover { background: #1d4ed8; }
|
|
305
|
+
#send:disabled { background: #aaa; cursor: default; }
|
|
306
|
+
#cancel-btn { background: #ef4444; color: #fff; border: none; padding: 10px 14px; border-radius: 8px; cursor: pointer; font-size: 15px; display: none; }
|
|
307
|
+
#cancel-btn:hover { background: #dc2626; }
|
|
308
|
+
|
|
309
|
+
/* Drop overlay */
|
|
310
|
+
#drop-overlay { display:none; position:fixed; inset:0; background:rgba(74,144,217,0.15); border:3px dashed #4a90d9; z-index:100; align-items:center; justify-content:center; font-size:24px; color:#4a90d9; pointer-events:none; }
|
|
311
|
+
#drop-overlay.active { display:flex; }
|
|
312
|
+
|
|
313
|
+
/* Connection indicator dot */
|
|
314
|
+
#conn-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; background: #ccc; transition: background 0.4s; }
|
|
315
|
+
#conn-dot.connected { background: #22c55e; }
|
|
316
|
+
#conn-dot.connecting { background: #f59e0b; animation: pulse-dot 1.2s ease-in-out infinite; }
|
|
317
|
+
#conn-dot.disconnected { background: #ef4444; }
|
|
318
|
+
@keyframes pulse-dot { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
|
|
319
|
+
|
|
320
|
+
/* Session drag-and-drop */
|
|
321
|
+
.session-item.drag-src { opacity: 0.45; }
|
|
322
|
+
.session-item.drag-over { border-top: 2px solid #2563eb; margin-top: -2px; }
|
|
323
|
+
|
|
324
|
+
/* Mobile: sidebar as overlay so it doesn't squish the chat area */
|
|
325
|
+
@media (max-width: 640px) {
|
|
326
|
+
#main-area { position: relative; }
|
|
327
|
+
#sidebar { position: absolute; top: 0; left: 0; height: 100%; z-index: 50; box-shadow: 2px 0 8px rgba(0,0,0,0.18); }
|
|
328
|
+
}
|
|
329
|
+
/* Backdrop sits behind the sidebar but above the chat — JS controls display */
|
|
330
|
+
#sidebar-backdrop { display: none; position: absolute; inset: 0; z-index: 49; background: transparent; }
|
|
331
|
+
</style>
|
|
332
|
+
</head>
|
|
333
|
+
<body>
|
|
334
|
+
<div id="drop-overlay">Dateien hier ablegen</div>
|
|
335
|
+
|
|
336
|
+
<div id="header">
|
|
337
|
+
<button id="toggle-sidebar" title="Sidebar ein-/ausblenden">☰</button>
|
|
338
|
+
<span id="header-title">NanoClaw — __SERVER_ADDRESS__</span>
|
|
339
|
+
<span id="header-cwd"></span>
|
|
340
|
+
<span id="conn-dot" title="Verbindet…"></span>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<div id="main-area">
|
|
344
|
+
<div id="sidebar-backdrop"></div>
|
|
345
|
+
<div id="sidebar">
|
|
346
|
+
<div id="sidebar-header">
|
|
347
|
+
<span id="sidebar-title">Chats</span>
|
|
348
|
+
<button id="new-session-btn" title="Neuen Chat starten">+</button>
|
|
349
|
+
</div>
|
|
350
|
+
<div id="session-list"></div>
|
|
351
|
+
</div>
|
|
352
|
+
<div id="messages"></div>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<div id="input-area">
|
|
356
|
+
<textarea id="input" rows="1" placeholder="Type a message…"></textarea>
|
|
357
|
+
<button id="cancel-btn" title="Anfrage abbrechen">✕</button>
|
|
358
|
+
<button id="send">Send</button>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
|
|
362
|
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
|
363
|
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
|
|
364
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
365
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/typescript.min.js"></script>
|
|
366
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/java.min.js"></script>
|
|
367
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
|
368
|
+
<script>
|
|
369
|
+
// ── Markdown renderer ──────────────────────────────────────────────────
|
|
370
|
+
const renderer = {
|
|
371
|
+
code(code, lang) {
|
|
372
|
+
if (lang === 'mermaid') return '<pre class="mermaid">' + code + '</pre>';
|
|
373
|
+
const language = (lang && hljs.getLanguage(lang)) ? lang : 'plaintext';
|
|
374
|
+
return '<pre><code class="hljs language-' + language + '">' +
|
|
375
|
+
hljs.highlight(code, { language }).value + '</code></pre>';
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
marked.use({ renderer, gfm: true, breaks: true });
|
|
379
|
+
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
|
380
|
+
|
|
381
|
+
// ── UUID helper (works in non-secure contexts, e.g. http:// on Android) ──
|
|
382
|
+
function uuid() {
|
|
383
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
|
|
384
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
385
|
+
const r = Math.random() * 16 | 0;
|
|
386
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Session storage helpers ────────────────────────────────────────────
|
|
391
|
+
function loadSessions() {
|
|
392
|
+
try {
|
|
393
|
+
const list = JSON.parse(localStorage.getItem('sessions') || '[]');
|
|
394
|
+
// Repair sessions with missing names (defensive, handles stale/corrupted data)
|
|
395
|
+
let repaired = false;
|
|
396
|
+
for (const s of list) {
|
|
397
|
+
if (!s.name) {
|
|
398
|
+
s.name = sessionLabel(new Date(s.createdAt || Date.now()));
|
|
399
|
+
// Use old timestamp (createdAt) so server wins on next merge if it has a real name
|
|
400
|
+
s.nameUpdatedAt = s.createdAt || 0;
|
|
401
|
+
repaired = true;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (repaired) localStorage.setItem('sessions', JSON.stringify(list));
|
|
405
|
+
return list;
|
|
406
|
+
} catch { return []; }
|
|
407
|
+
}
|
|
408
|
+
function saveSessions(list) { localStorage.setItem('sessions', JSON.stringify(list)); }
|
|
409
|
+
function sessionLabel(date) {
|
|
410
|
+
if (!date || isNaN(date.getTime())) date = new Date(); // guard against Invalid Date
|
|
411
|
+
try {
|
|
412
|
+
const s = date.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' });
|
|
413
|
+
if (s && s.length > 3) return s;
|
|
414
|
+
} catch {}
|
|
415
|
+
// Fallback for environments where Intl/de-DE is unavailable (e.g. some Android WebViews)
|
|
416
|
+
const p = n => String(n).padStart(2, '0');
|
|
417
|
+
return p(date.getDate()) + '.' + p(date.getMonth()+1) + '.' + String(date.getFullYear()).slice(2)
|
|
418
|
+
+ ', ' + p(date.getHours()) + ':' + p(date.getMinutes());
|
|
419
|
+
}
|
|
420
|
+
function saveCwd(sid, cwd) { localStorage.setItem('cwd:' + sid, cwd); }
|
|
421
|
+
function loadCwd(sid) { return localStorage.getItem('cwd:' + sid) || ''; }
|
|
422
|
+
|
|
423
|
+
function ensureSessionInList(sid) {
|
|
424
|
+
const list = loadSessions();
|
|
425
|
+
const existing = list.find(s => s.id === sid);
|
|
426
|
+
if (!existing) {
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
list.unshift({ id: sid, name: sessionLabel(new Date()), createdAt: now, isOwn: true, nameUpdatedAt: now });
|
|
429
|
+
saveSessions(list);
|
|
430
|
+
return true; // newly created
|
|
431
|
+
} else {
|
|
432
|
+
// Fix missing isOwn flag or empty name from previous versions
|
|
433
|
+
let changed = false;
|
|
434
|
+
if (!existing.isOwn) { existing.isOwn = true; changed = true; }
|
|
435
|
+
if (!existing.name) {
|
|
436
|
+
existing.name = sessionLabel(new Date(existing.createdAt || Date.now()));
|
|
437
|
+
// Use old timestamp so server wins on merge if it has a real name
|
|
438
|
+
existing.nameUpdatedAt = existing.createdAt || 0;
|
|
439
|
+
changed = true;
|
|
440
|
+
}
|
|
441
|
+
if (changed) saveSessions(list);
|
|
442
|
+
return false; // already existed
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Message history (per session, in localStorage) ─────────────────────
|
|
447
|
+
const MAX_HISTORY = 200;
|
|
448
|
+
function saveScroll(sid) {
|
|
449
|
+
if (!sid) return;
|
|
450
|
+
try { localStorage.setItem('scroll:' + sid, String(msgsEl.scrollTop)); } catch {}
|
|
451
|
+
}
|
|
452
|
+
function loadScroll(sid) {
|
|
453
|
+
try { const v = localStorage.getItem('scroll:' + sid); return v !== null ? parseInt(v, 10) : null; } catch { return null; }
|
|
454
|
+
}
|
|
455
|
+
function deleteScroll(sid) { localStorage.removeItem('scroll:' + sid); }
|
|
456
|
+
|
|
457
|
+
function appendHistory(sid, text, cls, msgId) {
|
|
458
|
+
try {
|
|
459
|
+
const key = 'hist:' + sid;
|
|
460
|
+
const hist = JSON.parse(localStorage.getItem(key) || '[]');
|
|
461
|
+
hist.push({ text, cls, id: msgId || null });
|
|
462
|
+
if (hist.length > MAX_HISTORY) hist.splice(0, hist.length - MAX_HISTORY);
|
|
463
|
+
localStorage.setItem(key, JSON.stringify(hist));
|
|
464
|
+
} catch {}
|
|
465
|
+
}
|
|
466
|
+
function loadHistory(sid) {
|
|
467
|
+
try { return JSON.parse(localStorage.getItem('hist:' + sid) || '[]'); } catch { return []; }
|
|
468
|
+
}
|
|
469
|
+
function deleteHistory(sid) { localStorage.removeItem('hist:' + sid); }
|
|
470
|
+
|
|
471
|
+
// ── DOM refs ──────────────────────────────────────────────────────────
|
|
472
|
+
const msgsEl = document.getElementById('messages');
|
|
473
|
+
const inputEl = document.getElementById('input');
|
|
474
|
+
const sendBtn = document.getElementById('send');
|
|
475
|
+
const sidebar = document.getElementById('sidebar');
|
|
476
|
+
const listEl = document.getElementById('session-list');
|
|
477
|
+
const headerTitle = document.getElementById('header-title');
|
|
478
|
+
const connDot = document.getElementById('conn-dot');
|
|
479
|
+
const headerCwd = document.getElementById('header-cwd');
|
|
480
|
+
const serverAddress = '__SERVER_ADDRESS__';
|
|
481
|
+
|
|
482
|
+
let typingEl = null;
|
|
483
|
+
let statusEl = null; // live tool-use status element (shown below typing indicator)
|
|
484
|
+
let sessionId = sessionStorage.getItem('sid');
|
|
485
|
+
let es = null; // EventSource
|
|
486
|
+
let sseGeneration = 0; // incremented each setupSSE() call; guards against stale events
|
|
487
|
+
let currentTyping = false; // last-known typing state from SSE
|
|
488
|
+
let currentStatus = null; // last-known status payload from SSE
|
|
489
|
+
let dragSrcId = null; // session being dragged
|
|
490
|
+
let dragOverEl = null; // item currently highlighted as drop target
|
|
491
|
+
let botHasResponded = false; // true after bot sends a reply; reset on new user message / session switch
|
|
492
|
+
|
|
493
|
+
// ── Unread tracking ───────────────────────────────────────────────────
|
|
494
|
+
// Persisted in localStorage ('seen:{sid}') so unread state survives reloads.
|
|
495
|
+
// A session is unread if server's lastMessage timestamp > last-seen timestamp.
|
|
496
|
+
const unreadSessions = new Set();
|
|
497
|
+
function loadLastSeen(sid) {
|
|
498
|
+
try { const v = localStorage.getItem('seen:' + sid); return v !== null ? parseInt(v, 10) : null; } catch { return null; }
|
|
499
|
+
}
|
|
500
|
+
function saveLastSeen(sid, ts) {
|
|
501
|
+
try { localStorage.setItem('seen:' + sid, String(ts)); } catch {}
|
|
502
|
+
}
|
|
503
|
+
function markRead(sid) {
|
|
504
|
+
saveLastSeen(sid, Date.now());
|
|
505
|
+
if (unreadSessions.delete(sid)) renderSessions();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── Connection indicator ──────────────────────────────────────────────
|
|
509
|
+
const connLabels = { connecting: 'Verbindet…', connected: 'Verbunden', disconnected: 'Getrennt' };
|
|
510
|
+
function setConnState(state) {
|
|
511
|
+
connDot.className = state;
|
|
512
|
+
connDot.title = connLabels[state] || '';
|
|
513
|
+
}
|
|
514
|
+
setConnState('connecting');
|
|
515
|
+
|
|
516
|
+
// ── Sidebar toggle ────────────────────────────────────────────────────
|
|
517
|
+
const isMobile = window.innerWidth <= 640;
|
|
518
|
+
// On mobile: always open initially (overlay mode); on desktop: respect saved pref
|
|
519
|
+
let sidebarOpen = isMobile ? true : (localStorage.getItem('sidebarOpen') !== 'false');
|
|
520
|
+
const backdrop = document.getElementById('sidebar-backdrop');
|
|
521
|
+
function setSidebar(open) {
|
|
522
|
+
sidebarOpen = open;
|
|
523
|
+
// Only persist state on desktop — on mobile sidebar is always an overlay
|
|
524
|
+
if (!isMobile) localStorage.setItem('sidebarOpen', String(open));
|
|
525
|
+
sidebar.classList.toggle('collapsed', !open);
|
|
526
|
+
// Backdrop only active (visible + interactive) when sidebar is open on mobile
|
|
527
|
+
if (backdrop) backdrop.style.display = (isMobile && open) ? 'block' : 'none';
|
|
528
|
+
}
|
|
529
|
+
setSidebar(sidebarOpen);
|
|
530
|
+
document.getElementById('toggle-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen));
|
|
531
|
+
// Tap backdrop to close sidebar on mobile
|
|
532
|
+
if (backdrop) backdrop.addEventListener('click', () => setSidebar(false));
|
|
533
|
+
|
|
534
|
+
// ── Header ────────────────────────────────────────────────────────────
|
|
535
|
+
function updateHeader(cwd) {
|
|
536
|
+
headerTitle.textContent = 'NanoClaw \u2014 ' + serverAddress;
|
|
537
|
+
headerCwd.textContent = ' \u2014 /' + (cwd || '');
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── Messages ──────────────────────────────────────────────────────────
|
|
541
|
+
const ICON_COPY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
542
|
+
const ICON_CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
|
543
|
+
const ICON_TRASH = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
|
|
544
|
+
|
|
545
|
+
function flashCopied(btn) {
|
|
546
|
+
btn.innerHTML = ICON_CHECK;
|
|
547
|
+
btn.classList.add('copied');
|
|
548
|
+
setTimeout(() => { btn.innerHTML = ICON_COPY; btn.classList.remove('copied'); }, 1500);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renderMsg(text, cls, msgId) {
|
|
552
|
+
const row = document.createElement('div');
|
|
553
|
+
row.className = 'msg-row ' + (cls.startsWith('bot') || cls === 'status' ? 'bot' : 'user');
|
|
554
|
+
const d = document.createElement('div');
|
|
555
|
+
d.className = 'msg ' + cls;
|
|
556
|
+
if (cls === 'bot') {
|
|
557
|
+
d.innerHTML = marked.parse(text);
|
|
558
|
+
renderMathInElement(d, {
|
|
559
|
+
delimiters: [
|
|
560
|
+
{ left: '$$', right: '$$', display: true },
|
|
561
|
+
{ left: '$', right: '$', display: false },
|
|
562
|
+
{ left: '\\\\[', right: '\\\\]', display: true },
|
|
563
|
+
{ left: '\\\\(', right: '\\\\)', display: false }
|
|
564
|
+
],
|
|
565
|
+
throwOnError: false
|
|
566
|
+
});
|
|
567
|
+
try { mermaid.run({ nodes: d.querySelectorAll('.mermaid') }); } catch { /* mermaid bug in strict mode — ignore */ }
|
|
568
|
+
// Add copy button to each fenced code block
|
|
569
|
+
d.querySelectorAll('pre').forEach(pre => {
|
|
570
|
+
const btn = document.createElement('button');
|
|
571
|
+
btn.className = 'copy-btn';
|
|
572
|
+
btn.innerHTML = ICON_COPY;
|
|
573
|
+
btn.title = 'Code kopieren';
|
|
574
|
+
btn.addEventListener('click', e => {
|
|
575
|
+
e.stopPropagation();
|
|
576
|
+
const code = pre.querySelector('code');
|
|
577
|
+
navigator.clipboard.writeText(code ? code.innerText : pre.innerText).then(() => flashCopied(btn)).catch(() => {});
|
|
578
|
+
});
|
|
579
|
+
pre.appendChild(btn);
|
|
580
|
+
});
|
|
581
|
+
// Add copy-whole-message button (top-right, same line as trash)
|
|
582
|
+
const copyBtn = document.createElement('button');
|
|
583
|
+
copyBtn.className = 'copy-btn';
|
|
584
|
+
copyBtn.innerHTML = ICON_COPY;
|
|
585
|
+
copyBtn.title = 'Antwort kopieren';
|
|
586
|
+
copyBtn.addEventListener('click', () => {
|
|
587
|
+
navigator.clipboard.writeText(text).then(() => flashCopied(copyBtn)).catch(() => {});
|
|
588
|
+
});
|
|
589
|
+
d.appendChild(copyBtn);
|
|
590
|
+
} else {
|
|
591
|
+
d.textContent = text;
|
|
592
|
+
}
|
|
593
|
+
// Trash button — inside the bubble, top corner, always visible; only for real messages with a DB id
|
|
594
|
+
row.appendChild(d);
|
|
595
|
+
// Trash button — outside the bubble, always visible; only for real messages with a DB id
|
|
596
|
+
if (msgId && cls !== 'bot typing' && cls !== 'status') {
|
|
597
|
+
const delBtn = document.createElement('button');
|
|
598
|
+
delBtn.className = 'del-btn';
|
|
599
|
+
delBtn.title = 'Nachricht löschen';
|
|
600
|
+
delBtn.innerHTML = ICON_TRASH;
|
|
601
|
+
delBtn.addEventListener('click', async () => {
|
|
602
|
+
try {
|
|
603
|
+
await fetch('/delete-message', {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
headers: { 'Content-Type': 'application/json' },
|
|
606
|
+
body: JSON.stringify({ sid: sessionId, id: msgId }),
|
|
607
|
+
});
|
|
608
|
+
row.remove();
|
|
609
|
+
// Remove from localStorage cache
|
|
610
|
+
try {
|
|
611
|
+
const hist = loadHistory(sessionId).filter(m => m.id !== msgId);
|
|
612
|
+
localStorage.setItem('hist:' + sessionId, JSON.stringify(hist));
|
|
613
|
+
} catch {}
|
|
614
|
+
} catch {}
|
|
615
|
+
});
|
|
616
|
+
row.appendChild(delBtn);
|
|
617
|
+
}
|
|
618
|
+
msgsEl.appendChild(row);
|
|
619
|
+
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
620
|
+
return row;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function addMsg(text, cls, msgId) {
|
|
624
|
+
// Save to history (skip typing indicator)
|
|
625
|
+
if (cls !== 'bot typing') appendHistory(sessionId, text, cls, msgId);
|
|
626
|
+
return renderMsg(text, cls, msgId);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const cancelBtn = document.getElementById('cancel-btn');
|
|
630
|
+
|
|
631
|
+
function setTyping(on) {
|
|
632
|
+
currentTyping = on;
|
|
633
|
+
if (on && !typingEl) { typingEl = renderMsg('…', 'bot typing'); }
|
|
634
|
+
if (!on && typingEl) { typingEl.remove(); typingEl = null; }
|
|
635
|
+
if (!on) setStatusDisplay(null); // clear status when typing stops
|
|
636
|
+
cancelBtn.style.display = on ? 'block' : 'none';
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
cancelBtn.addEventListener('click', () => {
|
|
640
|
+
fetch('/cancel?sid=' + sessionId, { method: 'POST' }).catch(() => {});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
/** Show/update/clear the live tool-use status line below the typing indicator. */
|
|
644
|
+
function setStatusDisplay(tool, inputSnippet) {
|
|
645
|
+
currentStatus = tool ? { tool, inputSnippet } : null;
|
|
646
|
+
if (!tool) {
|
|
647
|
+
if (statusEl) { statusEl.remove(); statusEl = null; }
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
// Format display text: tool name + optional brief input snippet
|
|
651
|
+
let label = tool === 'thinking' ? 'thinking\u2026' : tool + '\u2026';
|
|
652
|
+
if (inputSnippet && tool !== 'thinking') {
|
|
653
|
+
const snippet = inputSnippet.length > 60 ? inputSnippet.slice(0, 60) + '\u2026' : inputSnippet;
|
|
654
|
+
label = tool + ': ' + snippet;
|
|
655
|
+
}
|
|
656
|
+
if (!statusEl) {
|
|
657
|
+
statusEl = document.createElement('div');
|
|
658
|
+
statusEl.className = 'msg status';
|
|
659
|
+
msgsEl.appendChild(statusEl);
|
|
660
|
+
}
|
|
661
|
+
statusEl.textContent = label;
|
|
662
|
+
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function restoreHistory(sid) {
|
|
666
|
+
for (const { text, cls, id } of loadHistory(sid)) renderMsg(text, cls, id);
|
|
667
|
+
const saved = loadScroll(sid);
|
|
668
|
+
msgsEl.scrollTop = saved !== null ? saved : msgsEl.scrollHeight;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function fetchServerHistory(sid) {
|
|
672
|
+
try {
|
|
673
|
+
const resp = await fetch('/history?sid=' + sid);
|
|
674
|
+
if (!resp.ok) return;
|
|
675
|
+
if (sid !== sessionId) return; // session changed while fetching — discard stale result
|
|
676
|
+
const hist = await resp.json();
|
|
677
|
+
if (sid !== sessionId) return; // check again after JSON parse
|
|
678
|
+
msgsEl.innerHTML = '';
|
|
679
|
+
typingEl = null;
|
|
680
|
+
statusEl = null;
|
|
681
|
+
for (const { text, cls, id } of hist) renderMsg(text, cls, id);
|
|
682
|
+
// Re-append typing/status indicators only if the agent is still processing.
|
|
683
|
+
// If the last DB message is already a bot response, the agent has finished —
|
|
684
|
+
// force-clear any stale typing/status state (server's sessionTyping may lag
|
|
685
|
+
// behind due to a race between the bot response and an SSE reconnect).
|
|
686
|
+
const agentDone = hist.length > 0 && hist[hist.length - 1].cls === 'bot';
|
|
687
|
+
if (agentDone) {
|
|
688
|
+
botHasResponded = true;
|
|
689
|
+
setTyping(false); // resets currentTyping + currentStatus via setStatusDisplay(null)
|
|
690
|
+
} else {
|
|
691
|
+
if (currentTyping) setTyping(true);
|
|
692
|
+
if (currentStatus) setStatusDisplay(currentStatus.tool, currentStatus.inputSnippet);
|
|
693
|
+
}
|
|
694
|
+
const saved = loadScroll(sid);
|
|
695
|
+
msgsEl.scrollTop = saved !== null ? saved : msgsEl.scrollHeight;
|
|
696
|
+
// Mark this session as read — history was fully loaded, user has "seen" all messages
|
|
697
|
+
markRead(sid);
|
|
698
|
+
// Cache in localStorage for faster access next time
|
|
699
|
+
try {
|
|
700
|
+
localStorage.setItem('hist:' + sid, JSON.stringify(
|
|
701
|
+
hist.slice(-MAX_HISTORY)
|
|
702
|
+
));
|
|
703
|
+
} catch {}
|
|
704
|
+
} catch {}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function mergeServerSessions() {
|
|
708
|
+
try {
|
|
709
|
+
const resp = await fetch('/sessions');
|
|
710
|
+
if (!resp.ok) return;
|
|
711
|
+
const payload = await resp.json();
|
|
712
|
+
// Response is now { sessions, order } — fall back to plain array for compat
|
|
713
|
+
const serverSessions = Array.isArray(payload) ? payload : (payload.sessions ?? []);
|
|
714
|
+
const serverOrder = Array.isArray(payload) ? [] : (payload.order ?? []);
|
|
715
|
+
const serverIds = new Set(serverSessions.map(ss => ss.id));
|
|
716
|
+
let local = loadSessions();
|
|
717
|
+
let changed = false;
|
|
718
|
+
|
|
719
|
+
// Add or update sessions from server
|
|
720
|
+
for (const ss of serverSessions) {
|
|
721
|
+
const existing = local.find(s => s.id === ss.id);
|
|
722
|
+
if (!existing) {
|
|
723
|
+
// New session from another window/browser — use server name, fall back to date label
|
|
724
|
+
const serverName = ss.name && ss.name !== 'Web Chat' ? ss.name : null;
|
|
725
|
+
const lastMsgDate = ss.lastMessage ? new Date(ss.lastMessage) : null;
|
|
726
|
+
const validDate = (lastMsgDate && !isNaN(lastMsgDate.getTime())) ? lastMsgDate : new Date();
|
|
727
|
+
// unshift() so new sessions appear at the top without re-sorting the whole
|
|
728
|
+
// list (which would destroy any manual drag-and-drop ordering).
|
|
729
|
+
local.unshift({
|
|
730
|
+
id: ss.id,
|
|
731
|
+
name: serverName || sessionLabel(validDate),
|
|
732
|
+
createdAt: validDate.getTime(),
|
|
733
|
+
fromServer: true,
|
|
734
|
+
nameUpdatedAt: ss.nameUpdatedAt || 0,
|
|
735
|
+
});
|
|
736
|
+
changed = true;
|
|
737
|
+
} else {
|
|
738
|
+
// Mark as known to server
|
|
739
|
+
if (!existing.fromServer) { existing.fromServer = true; changed = true; }
|
|
740
|
+
// Compare nameUpdatedAt timestamps to decide who wins.
|
|
741
|
+
// Default to createdAt (or 0) if nameUpdatedAt is missing (old data).
|
|
742
|
+
const localTs = existing.nameUpdatedAt ?? existing.createdAt ?? 0;
|
|
743
|
+
const serverTs = ss.nameUpdatedAt || 0;
|
|
744
|
+
if (serverTs > localTs && ss.name && ss.name !== 'Web Chat') {
|
|
745
|
+
// Server has a newer rename → update local
|
|
746
|
+
existing.name = ss.name;
|
|
747
|
+
existing.nameUpdatedAt = serverTs;
|
|
748
|
+
changed = true;
|
|
749
|
+
} else if (localTs > serverTs && existing.name && existing.name !== 'Web Chat' && existing.name !== ss.name) {
|
|
750
|
+
// Local is newer → push back to server so other devices sync
|
|
751
|
+
// (server-side guard prevents overwrite if server already has something newer)
|
|
752
|
+
pushNameToServer(existing.id, existing.name, localTs);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Remove local sessions that were from the server but are no longer there (deleted elsewhere)
|
|
758
|
+
const before = local.length;
|
|
759
|
+
let activeSessionRemoved = false;
|
|
760
|
+
local = local.filter(s => {
|
|
761
|
+
if (!s.fromServer) return true; // locally-created, not yet confirmed by server → keep
|
|
762
|
+
if (s.isOwn) return true; // created in this browser → never auto-remove
|
|
763
|
+
if (serverIds.has(s.id)) return true; // still on server → keep
|
|
764
|
+
if (s.id === sessionId) activeSessionRemoved = true;
|
|
765
|
+
deleteHistory(s.id);
|
|
766
|
+
localStorage.removeItem('cwd:' + s.id);
|
|
767
|
+
unreadSessions.delete(s.id);
|
|
768
|
+
return false; // remove
|
|
769
|
+
});
|
|
770
|
+
if (local.length !== before) {
|
|
771
|
+
changed = true;
|
|
772
|
+
if (activeSessionRemoved) {
|
|
773
|
+
// Switch to first remaining session after filter is fully applied
|
|
774
|
+
if (local.length > 0) setTimeout(() => switchSession(local[0].id), 0);
|
|
775
|
+
else setTimeout(newSession, 0);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Sync CWD from server for all sessions
|
|
780
|
+
for (const ss of serverSessions) {
|
|
781
|
+
if (ss.cwd) {
|
|
782
|
+
const localCwd = loadCwd(ss.id);
|
|
783
|
+
if (ss.cwd !== localCwd) {
|
|
784
|
+
saveCwd(ss.id, ss.cwd);
|
|
785
|
+
if (ss.id === sessionId) {
|
|
786
|
+
sessionStorage.setItem('currentCwd', ss.cwd);
|
|
787
|
+
updateHeader(ss.cwd);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Update unread indicators: a session is unread if the server's lastMessage
|
|
794
|
+
// is newer than the stored last-seen timestamp for that session.
|
|
795
|
+
for (const ss of serverSessions) {
|
|
796
|
+
if (ss.id === sessionId) { saveLastSeen(ss.id, Date.now()); continue; } // current session → always seen
|
|
797
|
+
if (!ss.lastMessage) continue;
|
|
798
|
+
const msgTs = new Date(ss.lastMessage).getTime();
|
|
799
|
+
const seen = loadLastSeen(ss.id);
|
|
800
|
+
if (seen === null) { saveLastSeen(ss.id, msgTs); continue; } // first time ever: init without marking unread
|
|
801
|
+
if (msgTs > seen && !unreadSessions.has(ss.id)) { unreadSessions.add(ss.id); changed = true; }
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Apply server-defined order if available (from /session-order endpoint).
|
|
805
|
+
// This ensures cross-device/cross-browser order sync.
|
|
806
|
+
if (serverOrder.length > 0) {
|
|
807
|
+
const orderMap = new Map(serverOrder.map((id, i) => [id, i]));
|
|
808
|
+
const maxOrder = serverOrder.length;
|
|
809
|
+
const prevOrder = local.map(s => s.id).join(',');
|
|
810
|
+
local.sort((a, b) => {
|
|
811
|
+
// isOwn sessions not yet confirmed in serverOrder stay above all server-ordered sessions
|
|
812
|
+
const rawIa = orderMap.get(a.id);
|
|
813
|
+
const rawIb = orderMap.get(b.id);
|
|
814
|
+
const ia = rawIa !== undefined ? rawIa : (a.isOwn ? -1 : maxOrder);
|
|
815
|
+
const ib = rawIb !== undefined ? rawIb : (b.isOwn ? -1 : maxOrder);
|
|
816
|
+
if (ia !== ib) return ia - ib;
|
|
817
|
+
// Sessions not in the order list: sort by newest first
|
|
818
|
+
return (b.createdAt || 0) - (a.createdAt || 0);
|
|
819
|
+
});
|
|
820
|
+
const newOrder = local.map(s => s.id).join(',');
|
|
821
|
+
if (newOrder !== prevOrder) changed = true;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (changed) {
|
|
825
|
+
saveSessions(local);
|
|
826
|
+
if (!isRenaming()) renderSessions();
|
|
827
|
+
}
|
|
828
|
+
} catch {}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
msgsEl.addEventListener('click', e => {
|
|
832
|
+
const a = e.target.closest('a');
|
|
833
|
+
if (!a) return;
|
|
834
|
+
const href = a.getAttribute('href') || '';
|
|
835
|
+
if (href.startsWith('topic:')) {
|
|
836
|
+
e.preventDefault();
|
|
837
|
+
inputEl.value = 'switching to topic: ' + href.slice('topic:'.length);
|
|
838
|
+
sendMsg();
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// ── SSE ───────────────────────────────────────────────────────────────
|
|
843
|
+
function setupSSE() {
|
|
844
|
+
if (es) es.close();
|
|
845
|
+
setConnState('connecting');
|
|
846
|
+
const myGen = ++sseGeneration; // guard: ignore events from superseded connections
|
|
847
|
+
const mySid = sessionId;
|
|
848
|
+
es = new EventSource('/events?sid=' + sessionId);
|
|
849
|
+
// On SSE open: push current session name to server so ensureSession() (which
|
|
850
|
+
// runs server-side on first connect and may default to "Web Chat") sees the
|
|
851
|
+
// correct name right away. This fixes the race on Android/mobile where the
|
|
852
|
+
// pushNameToServer from newSession() might arrive AFTER ensureSession() runs.
|
|
853
|
+
let sseEverConnected = false; // distinguish initial connect from reconnects
|
|
854
|
+
es.addEventListener('open', () => {
|
|
855
|
+
if (sseGeneration !== myGen || sessionId !== mySid) return;
|
|
856
|
+
const wasConnected = sseEverConnected;
|
|
857
|
+
sseEverConnected = true;
|
|
858
|
+
setConnState('connected');
|
|
859
|
+
// Push session name on every connect so ensureSession() on the server (which
|
|
860
|
+
// runs after the SSE handshake and may default to "Web Chat") always sees the
|
|
861
|
+
// correct name. The server-side guard (name_updated_at comparison) ensures
|
|
862
|
+
// a stale local push never overwrites a newer server-side rename.
|
|
863
|
+
const _s = loadSessions().find(s => s.id === sessionId);
|
|
864
|
+
if (_s?.name) pushNameToServer(sessionId, _s.name, _s.nameUpdatedAt ?? _s.createdAt ?? Date.now());
|
|
865
|
+
// On reconnect (not initial connect): re-fetch history to catch missed messages
|
|
866
|
+
// (e.g. bot response or typing: false delivered while the connection was down).
|
|
867
|
+
if (wasConnected) {
|
|
868
|
+
// Reset stale typing/status before re-fetching history. The SSE's
|
|
869
|
+
// initial typing/status events will restore the correct state.
|
|
870
|
+
setTyping(false);
|
|
871
|
+
fetchServerHistory(sessionId);
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
es.addEventListener('error', () => { if (sseGeneration === myGen) setConnState('disconnected'); });
|
|
875
|
+
es.addEventListener('message', e => {
|
|
876
|
+
if (sseGeneration !== myGen || sessionId !== mySid) return; // stale — discard
|
|
877
|
+
setStatusDisplay(null); // clear live status when response arrives
|
|
878
|
+
setTyping(false);
|
|
879
|
+
botHasResponded = true;
|
|
880
|
+
try {
|
|
881
|
+
const payload = JSON.parse(e.data);
|
|
882
|
+
// payload is either {text, id} (new format) or a plain string (backward compat)
|
|
883
|
+
const text = typeof payload === 'string' ? payload : payload.text;
|
|
884
|
+
const msgId = typeof payload === 'object' && payload ? payload.id : null;
|
|
885
|
+
addMsg(text, 'bot', msgId);
|
|
886
|
+
} catch { /* render error — don't block markRead */ }
|
|
887
|
+
markRead(sessionId); // message arrived in active session → keep it read
|
|
888
|
+
});
|
|
889
|
+
es.addEventListener('typing', e => {
|
|
890
|
+
if (sseGeneration !== myGen || sessionId !== mySid) return;
|
|
891
|
+
// Ignore stale "typing: true" from the server's initial SSE state push
|
|
892
|
+
// if the bot has already sent its response (race: SSE events arrive after
|
|
893
|
+
// fetchServerHistory has already confirmed agentDone).
|
|
894
|
+
if (e.data === 'true' && botHasResponded) return;
|
|
895
|
+
setTyping(e.data === 'true');
|
|
896
|
+
});
|
|
897
|
+
es.addEventListener('status', e => {
|
|
898
|
+
if (sseGeneration !== myGen || sessionId !== mySid) return;
|
|
899
|
+
try {
|
|
900
|
+
const payload = JSON.parse(e.data);
|
|
901
|
+
if (!payload) { setStatusDisplay(null); return; }
|
|
902
|
+
// Ignore stale status (e.g. "thinking") if bot has already responded
|
|
903
|
+
if (botHasResponded) return;
|
|
904
|
+
setStatusDisplay(payload.tool || null, payload.input || null);
|
|
905
|
+
} catch { /* ignore malformed status event */ }
|
|
906
|
+
});
|
|
907
|
+
es.addEventListener('cwd', e => {
|
|
908
|
+
if (sseGeneration !== myGen || sessionId !== mySid) return;
|
|
909
|
+
try {
|
|
910
|
+
const cwd = JSON.parse(e.data);
|
|
911
|
+
saveCwd(sessionId, cwd);
|
|
912
|
+
sessionStorage.setItem('currentCwd', cwd);
|
|
913
|
+
updateHeader(cwd);
|
|
914
|
+
} catch { /* ignore malformed cwd event */ }
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── Session management ────────────────────────────────────────────────
|
|
919
|
+
function setInputEnabled(enabled) {
|
|
920
|
+
inputEl.disabled = !enabled;
|
|
921
|
+
sendBtn.disabled = !enabled;
|
|
922
|
+
inputEl.placeholder = enabled ? 'Type a message\u2026' : 'Dieses Tab ist für geplante Aufgaben reserviert.';
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function switchSession(newId) {
|
|
926
|
+
saveScroll(sessionId); // persist scroll position of outgoing session
|
|
927
|
+
sessionId = newId; // set before markRead so renderSessions sees correct active session
|
|
928
|
+
markRead(newId); // clear unread indicator (uses updated sessionId)
|
|
929
|
+
sessionStorage.setItem('sid', newId);
|
|
930
|
+
msgsEl.innerHTML = '';
|
|
931
|
+
typingEl = null; // reset before setTyping so it doesn't try to .remove() a stale element
|
|
932
|
+
statusEl = null; // reset before setStatusDisplay for the same reason
|
|
933
|
+
botHasResponded = false; // reset: we don't yet know if the new session's bot has responded
|
|
934
|
+
setTyping(false); // resets currentTyping, hides cancel button, clears status via setStatusDisplay(null)
|
|
935
|
+
setInputEnabled(newId !== 'cron');
|
|
936
|
+
// Always fetch complete history from the server so that user messages
|
|
937
|
+
// written in other browser tabs/windows are not missing. (If only bot
|
|
938
|
+
// responses were cached locally via SSE, restoreHistory would skip user
|
|
939
|
+
// messages entirely.)
|
|
940
|
+
restoreHistory(newId); // show local cache immediately while fetching
|
|
941
|
+
fetchServerHistory(newId); // always sync complete history from DB
|
|
942
|
+
const cwd = loadCwd(newId);
|
|
943
|
+
sessionStorage.setItem('currentCwd', cwd);
|
|
944
|
+
updateHeader(cwd);
|
|
945
|
+
setupSSE();
|
|
946
|
+
renderSessions();
|
|
947
|
+
if (newId !== 'cron') inputEl.focus();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function pushNameToServer(sid, name, nameUpdatedAt) {
|
|
951
|
+
fetch('/session-name', {
|
|
952
|
+
method: 'POST',
|
|
953
|
+
headers: { 'Content-Type': 'application/json' },
|
|
954
|
+
body: JSON.stringify({ sid, name, nameUpdatedAt: nameUpdatedAt ?? Date.now() }),
|
|
955
|
+
}).catch(() => {});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function syncOrderToServer(list) {
|
|
959
|
+
fetch('/session-order', {
|
|
960
|
+
method: 'POST',
|
|
961
|
+
headers: { 'Content-Type': 'application/json' },
|
|
962
|
+
body: JSON.stringify({ order: list.map(x => x.id) }),
|
|
963
|
+
}).catch(() => {});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function newSession() {
|
|
967
|
+
const id = uuid();
|
|
968
|
+
const name = sessionLabel(new Date());
|
|
969
|
+
const nameUpdatedAt = Date.now();
|
|
970
|
+
const list = loadSessions();
|
|
971
|
+
list.unshift({ id, name, createdAt: nameUpdatedAt, isOwn: true, nameUpdatedAt });
|
|
972
|
+
saveSessions(list);
|
|
973
|
+
pushNameToServer(id, name, nameUpdatedAt);
|
|
974
|
+
syncOrderToServer(list);
|
|
975
|
+
switchSession(id);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function deleteSession(id) {
|
|
979
|
+
let list = loadSessions().filter(s => s.id !== id);
|
|
980
|
+
localStorage.removeItem('cwd:' + id);
|
|
981
|
+
deleteHistory(id);
|
|
982
|
+
deleteScroll(id);
|
|
983
|
+
unreadSessions.delete(id);
|
|
984
|
+
// Notify server so other browsers can sync the deletion
|
|
985
|
+
fetch('/delete-session', {
|
|
986
|
+
method: 'POST',
|
|
987
|
+
headers: { 'Content-Type': 'application/json' },
|
|
988
|
+
body: JSON.stringify({ sid: id }),
|
|
989
|
+
}).catch(() => {});
|
|
990
|
+
if (list.length === 0) {
|
|
991
|
+
// Always keep at least one session
|
|
992
|
+
const newId = uuid();
|
|
993
|
+
const newName = sessionLabel(new Date());
|
|
994
|
+
const newNameUpdatedAt = Date.now();
|
|
995
|
+
list = [{ id: newId, name: newName, createdAt: newNameUpdatedAt, isOwn: true, nameUpdatedAt: newNameUpdatedAt }];
|
|
996
|
+
saveSessions(list);
|
|
997
|
+
pushNameToServer(newId, newName, newNameUpdatedAt);
|
|
998
|
+
switchSession(newId);
|
|
999
|
+
} else {
|
|
1000
|
+
saveSessions(list);
|
|
1001
|
+
if (id === sessionId) switchSession(list[0].id);
|
|
1002
|
+
else renderSessions();
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function renameSession(id, newName) {
|
|
1007
|
+
const list = loadSessions();
|
|
1008
|
+
const s = list.find(s => s.id === id);
|
|
1009
|
+
if (s && newName.trim()) {
|
|
1010
|
+
s.name = newName.trim();
|
|
1011
|
+
s.nameUpdatedAt = Date.now();
|
|
1012
|
+
saveSessions(list);
|
|
1013
|
+
pushNameToServer(id, s.name, s.nameUpdatedAt);
|
|
1014
|
+
}
|
|
1015
|
+
renderSessions();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function startRename(id, nameEl) {
|
|
1019
|
+
const current = nameEl.textContent;
|
|
1020
|
+
const inp = document.createElement('input');
|
|
1021
|
+
inp.className = 'session-name-input';
|
|
1022
|
+
inp.value = current;
|
|
1023
|
+
nameEl.replaceWith(inp);
|
|
1024
|
+
inp.focus();
|
|
1025
|
+
inp.select();
|
|
1026
|
+
const commit = () => { renameSession(id, inp.value || current); };
|
|
1027
|
+
inp.addEventListener('blur', commit);
|
|
1028
|
+
inp.addEventListener('keydown', e => {
|
|
1029
|
+
if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
|
|
1030
|
+
if (e.key === 'Escape') { inp.value = current; inp.blur(); }
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Returns true while a session rename input is active — suppresses background re-renders. */
|
|
1035
|
+
function isRenaming() {
|
|
1036
|
+
return !!listEl.querySelector('.session-name-input');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function renderSessions() {
|
|
1040
|
+
const list = loadSessions();
|
|
1041
|
+
listEl.innerHTML = '';
|
|
1042
|
+
for (const s of list) {
|
|
1043
|
+
const item = document.createElement('div');
|
|
1044
|
+
const isUnread = unreadSessions.has(s.id) && s.id !== sessionId;
|
|
1045
|
+
item.className = 'session-item' + (s.id === sessionId ? ' active' : '') + (isUnread ? ' unread' : '');
|
|
1046
|
+
item.dataset.id = s.id;
|
|
1047
|
+
item.draggable = true;
|
|
1048
|
+
|
|
1049
|
+
const dot = document.createElement('span');
|
|
1050
|
+
dot.className = 'session-unread-dot';
|
|
1051
|
+
|
|
1052
|
+
const nameSpan = document.createElement('span');
|
|
1053
|
+
nameSpan.className = 'session-name';
|
|
1054
|
+
const displayName = s.name || sessionLabel(new Date(s.createdAt || Date.now()));
|
|
1055
|
+
nameSpan.textContent = displayName;
|
|
1056
|
+
nameSpan.title = displayName;
|
|
1057
|
+
|
|
1058
|
+
const actions = document.createElement('div');
|
|
1059
|
+
actions.className = 'session-actions';
|
|
1060
|
+
|
|
1061
|
+
const renameBtn = document.createElement('button');
|
|
1062
|
+
renameBtn.className = 'session-btn';
|
|
1063
|
+
renameBtn.title = 'Umbenennen';
|
|
1064
|
+
renameBtn.textContent = '✏';
|
|
1065
|
+
renameBtn.addEventListener('click', e => { e.stopPropagation(); startRename(s.id, nameSpan); });
|
|
1066
|
+
|
|
1067
|
+
const delBtn = document.createElement('button');
|
|
1068
|
+
delBtn.className = 'session-btn';
|
|
1069
|
+
delBtn.title = 'Löschen';
|
|
1070
|
+
delBtn.textContent = '×';
|
|
1071
|
+
delBtn.addEventListener('click', e => { e.stopPropagation(); deleteSession(s.id); });
|
|
1072
|
+
|
|
1073
|
+
actions.append(renameBtn, delBtn);
|
|
1074
|
+
item.append(dot, nameSpan, actions);
|
|
1075
|
+
item.addEventListener('click', () => {
|
|
1076
|
+
if (s.id !== sessionId) switchSession(s.id);
|
|
1077
|
+
if (isMobile) setSidebar(false); // close overlay after selection on mobile
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// ── Drag-and-drop reordering ────────────────────────────────────
|
|
1081
|
+
item.addEventListener('dragstart', e => {
|
|
1082
|
+
dragSrcId = s.id;
|
|
1083
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1084
|
+
item.classList.add('drag-src');
|
|
1085
|
+
});
|
|
1086
|
+
item.addEventListener('dragend', () => {
|
|
1087
|
+
dragSrcId = null;
|
|
1088
|
+
item.classList.remove('drag-src');
|
|
1089
|
+
if (dragOverEl) { dragOverEl.classList.remove('drag-over'); dragOverEl = null; }
|
|
1090
|
+
});
|
|
1091
|
+
item.addEventListener('dragover', e => {
|
|
1092
|
+
if (!dragSrcId || dragSrcId === s.id) return;
|
|
1093
|
+
e.preventDefault();
|
|
1094
|
+
e.dataTransfer.dropEffect = 'move';
|
|
1095
|
+
if (dragOverEl && dragOverEl !== item) { dragOverEl.classList.remove('drag-over'); }
|
|
1096
|
+
dragOverEl = item;
|
|
1097
|
+
item.classList.add('drag-over');
|
|
1098
|
+
});
|
|
1099
|
+
item.addEventListener('dragleave', e => {
|
|
1100
|
+
// Only remove if leaving the item entirely (not entering a child)
|
|
1101
|
+
if (!item.contains(e.relatedTarget)) {
|
|
1102
|
+
item.classList.remove('drag-over');
|
|
1103
|
+
if (dragOverEl === item) dragOverEl = null;
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
item.addEventListener('drop', e => {
|
|
1107
|
+
e.preventDefault();
|
|
1108
|
+
item.classList.remove('drag-over');
|
|
1109
|
+
dragOverEl = null;
|
|
1110
|
+
if (!dragSrcId || dragSrcId === s.id) return;
|
|
1111
|
+
const sessions = loadSessions();
|
|
1112
|
+
const fromIdx = sessions.findIndex(x => x.id === dragSrcId);
|
|
1113
|
+
const toIdx = sessions.findIndex(x => x.id === s.id);
|
|
1114
|
+
if (fromIdx < 0 || toIdx < 0) return;
|
|
1115
|
+
const [moved] = sessions.splice(fromIdx, 1);
|
|
1116
|
+
sessions.splice(toIdx, 0, moved);
|
|
1117
|
+
saveSessions(sessions);
|
|
1118
|
+
renderSessions();
|
|
1119
|
+
syncOrderToServer(sessions);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
listEl.appendChild(item);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// ── Init ──────────────────────────────────────────────────────────────
|
|
1127
|
+
if (!sessionId) {
|
|
1128
|
+
sessionId = uuid();
|
|
1129
|
+
sessionStorage.setItem('sid', sessionId);
|
|
1130
|
+
}
|
|
1131
|
+
const isNewSession = ensureSessionInList(sessionId);
|
|
1132
|
+
// For new sessions: push name to server immediately.
|
|
1133
|
+
// For existing sessions: SSE open event handles it (with nameUpdatedAt so server
|
|
1134
|
+
// guard ensures only a genuinely newer name overwrites a server-side rename).
|
|
1135
|
+
if (isNewSession) {
|
|
1136
|
+
const _s = loadSessions().find(s => s.id === sessionId);
|
|
1137
|
+
if (_s?.name) pushNameToServer(sessionId, _s.name, _s.nameUpdatedAt ?? _s.createdAt ?? Date.now());
|
|
1138
|
+
}
|
|
1139
|
+
markRead(sessionId); // ensure current session never appears as unread on first poll
|
|
1140
|
+
setInputEnabled(sessionId !== 'cron');
|
|
1141
|
+
renderSessions();
|
|
1142
|
+
restoreHistory(sessionId); // show local cache immediately (fast)
|
|
1143
|
+
fetchServerHistory(sessionId); // sync full history from DB (catches cross-device messages)
|
|
1144
|
+
const initCwd = loadCwd(sessionId) || sessionStorage.getItem('currentCwd') || '';
|
|
1145
|
+
updateHeader(initCwd);
|
|
1146
|
+
setupSSE();
|
|
1147
|
+
|
|
1148
|
+
document.getElementById('new-session-btn').addEventListener('click', newSession);
|
|
1149
|
+
mergeServerSessions(); // populate sidebar with sessions known to the server
|
|
1150
|
+
|
|
1151
|
+
// ── Cross-window sync ─────────────────────────────────────────────────
|
|
1152
|
+
let lastSessionsJson = localStorage.getItem('sessions');
|
|
1153
|
+
function checkAndSyncSessions() {
|
|
1154
|
+
const current = localStorage.getItem('sessions');
|
|
1155
|
+
if (current !== lastSessionsJson) {
|
|
1156
|
+
lastSessionsJson = current;
|
|
1157
|
+
if (!isRenaming()) renderSessions();
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
// Primary: storage event (fires immediately when another tab in the same browser changes localStorage)
|
|
1161
|
+
window.addEventListener('storage', e => {
|
|
1162
|
+
if (e.key === 'sessions' || e.key === null) {
|
|
1163
|
+
lastSessionsJson = localStorage.getItem('sessions');
|
|
1164
|
+
if (!isRenaming()) renderSessions();
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
// Fast polling for same-browser cross-tab sync (fallback)
|
|
1168
|
+
setInterval(checkAndSyncSessions, 1000);
|
|
1169
|
+
// Server polling: picks up sessions from other browsers/devices
|
|
1170
|
+
setInterval(mergeServerSessions, 5000);
|
|
1171
|
+
|
|
1172
|
+
// ── Send message ──────────────────────────────────────────────────────
|
|
1173
|
+
async function sendMsg() {
|
|
1174
|
+
const text = inputEl.value.trim();
|
|
1175
|
+
if (!text) return;
|
|
1176
|
+
inputEl.value = '';
|
|
1177
|
+
inputEl.style.height = '';
|
|
1178
|
+
|
|
1179
|
+
// ── Built-in commands (not forwarded to bot) ──────────────────────────
|
|
1180
|
+
if (text === '/clear') {
|
|
1181
|
+
msgsEl.innerHTML = '';
|
|
1182
|
+
typingEl = null;
|
|
1183
|
+
deleteHistory(sessionId);
|
|
1184
|
+
fetch('/clear-history', {
|
|
1185
|
+
method: 'POST',
|
|
1186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1187
|
+
body: JSON.stringify({ sid: sessionId }),
|
|
1188
|
+
}).catch(() => {});
|
|
1189
|
+
inputEl.focus();
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
botHasResponded = false; // reset: new user message, bot hasn't responded to this one yet
|
|
1194
|
+
// Generate ID client-side so we can assign it to the DOM element immediately
|
|
1195
|
+
const userMsgId = 'web-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
1196
|
+
addMsg(text, 'user', userMsgId);
|
|
1197
|
+
sendBtn.disabled = true;
|
|
1198
|
+
try {
|
|
1199
|
+
const cwdMatch = text.match(/^switching to topic:\\s*(.+)$/i);
|
|
1200
|
+
if (cwdMatch) {
|
|
1201
|
+
const cwd = 'Topics/' + cwdMatch[1].trim();
|
|
1202
|
+
saveCwd(sessionId, cwd);
|
|
1203
|
+
sessionStorage.setItem('currentCwd', cwd);
|
|
1204
|
+
updateHeader(cwd);
|
|
1205
|
+
}
|
|
1206
|
+
await fetch('/message', {
|
|
1207
|
+
method: 'POST',
|
|
1208
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1209
|
+
body: JSON.stringify({ content: text, sessionId, id: userMsgId }),
|
|
1210
|
+
});
|
|
1211
|
+
} finally {
|
|
1212
|
+
sendBtn.disabled = false;
|
|
1213
|
+
inputEl.focus();
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
sendBtn.addEventListener('click', sendMsg);
|
|
1218
|
+
inputEl.addEventListener('keydown', e => {
|
|
1219
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
|
|
1220
|
+
});
|
|
1221
|
+
inputEl.addEventListener('input', () => {
|
|
1222
|
+
inputEl.style.height = 'auto';
|
|
1223
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
// ── File drop ─────────────────────────────────────────────────────────
|
|
1227
|
+
const overlay = document.getElementById('drop-overlay');
|
|
1228
|
+
function toBase64(buf) {
|
|
1229
|
+
let binary = '';
|
|
1230
|
+
const bytes = new Uint8Array(buf);
|
|
1231
|
+
for (let i = 0; i < bytes.length; i += 8192)
|
|
1232
|
+
binary += String.fromCharCode(...bytes.slice(i, i + 8192));
|
|
1233
|
+
return btoa(binary);
|
|
1234
|
+
}
|
|
1235
|
+
document.addEventListener('dragover', e => { e.preventDefault(); overlay.classList.add('active'); });
|
|
1236
|
+
document.addEventListener('dragleave', e => { if (!e.relatedTarget) overlay.classList.remove('active'); });
|
|
1237
|
+
document.addEventListener('drop', async e => {
|
|
1238
|
+
e.preventDefault();
|
|
1239
|
+
overlay.classList.remove('active');
|
|
1240
|
+
for (const file of [...e.dataTransfer.files]) {
|
|
1241
|
+
const b64 = toBase64(await file.arrayBuffer());
|
|
1242
|
+
const res = await fetch('/upload', {
|
|
1243
|
+
method: 'POST',
|
|
1244
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1245
|
+
body: JSON.stringify({ filename: file.name, data: b64, sessionId })
|
|
1246
|
+
});
|
|
1247
|
+
const json = await res.json();
|
|
1248
|
+
addMsg(json.ok ? 'Gespeichert: ' + json.path : 'Fehler: ' + json.error, 'bot');
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
</script>
|
|
1252
|
+
</body>`;
|
|
1253
|
+
|
|
1254
|
+
class WebChannel {
|
|
1255
|
+
name = 'web';
|
|
1256
|
+
private server: http.Server | null = null;
|
|
1257
|
+
private connected = false;
|
|
1258
|
+
private onMessage: ChannelOpts['onMessage'];
|
|
1259
|
+
private onChatMetadata: ChannelOpts['onChatMetadata'];
|
|
1260
|
+
private registerGroup: ChannelOpts['registerGroup'];
|
|
1261
|
+
private onCancelRequest: ChannelOpts['onCancelRequest'];
|
|
1262
|
+
|
|
1263
|
+
constructor(opts: ChannelOpts) {
|
|
1264
|
+
this.onMessage = opts.onMessage;
|
|
1265
|
+
this.onChatMetadata = opts.onChatMetadata;
|
|
1266
|
+
this.registerGroup = opts.registerGroup;
|
|
1267
|
+
this.onCancelRequest = opts.onCancelRequest;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
private ensureSession(sessionId: string): void {
|
|
1271
|
+
if (registeredSessions.has(sessionId)) return;
|
|
1272
|
+
registeredSessions.add(sessionId);
|
|
1273
|
+
const jid = WEB_JID_PREFIX + sessionId;
|
|
1274
|
+
// Preserve custom name if already set (e.g. by /session-name endpoint)
|
|
1275
|
+
const existing = getAllChats().find((c) => c.jid === jid);
|
|
1276
|
+
const chatName =
|
|
1277
|
+
existing?.name && existing.name !== GROUP_NAME
|
|
1278
|
+
? existing.name
|
|
1279
|
+
: GROUP_NAME;
|
|
1280
|
+
if (this.registerGroup) {
|
|
1281
|
+
// Each web session gets its own IPC-isolated folder so that parallel
|
|
1282
|
+
// containers don't accidentally read each other's follow-up IPC messages.
|
|
1283
|
+
//
|
|
1284
|
+
// • Cron session → CRON_GROUP_FOLDER ('web-cron') — own workspace + IPC
|
|
1285
|
+
// • User sessions → 'web-{sessionId}' — unique IPC directory, but the
|
|
1286
|
+
// workspace (groups/main/) and Claude sessions dir (data/sessions/main/)
|
|
1287
|
+
// are shared via symlinks so all sessions see the same agent context,
|
|
1288
|
+
// memory, skills, tools, etc.
|
|
1289
|
+
let folder: string;
|
|
1290
|
+
if (sessionId === CRON_SESSION_ID) {
|
|
1291
|
+
folder = CRON_GROUP_FOLDER;
|
|
1292
|
+
} else {
|
|
1293
|
+
folder = 'web-' + sessionId;
|
|
1294
|
+
// Create symlinks so this session's containers share the main workspace
|
|
1295
|
+
// and .claude directory while getting an isolated IPC directory.
|
|
1296
|
+
const groupLink = path.join(GROUPS_DIR, folder);
|
|
1297
|
+
if (!fs.existsSync(groupLink)) {
|
|
1298
|
+
try {
|
|
1299
|
+
fs.symlinkSync('main', groupLink);
|
|
1300
|
+
} catch {
|
|
1301
|
+
/* already exists */
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
const sessionsLink = path.join(DATA_DIR, 'sessions', folder);
|
|
1305
|
+
if (!fs.existsSync(sessionsLink)) {
|
|
1306
|
+
try {
|
|
1307
|
+
fs.symlinkSync('main', sessionsLink);
|
|
1308
|
+
} catch {
|
|
1309
|
+
/* already exists */
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
this.registerGroup(jid, {
|
|
1314
|
+
name: chatName,
|
|
1315
|
+
folder,
|
|
1316
|
+
trigger: '',
|
|
1317
|
+
added_at: new Date().toISOString(),
|
|
1318
|
+
requiresTrigger: false,
|
|
1319
|
+
isMain: true,
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
// Use epoch timestamp so MAX() in storeChatMetadata never overwrites the actual
|
|
1323
|
+
// last_message_time — we only want to register name/channel/isGroup here.
|
|
1324
|
+
this.onChatMetadata(
|
|
1325
|
+
jid,
|
|
1326
|
+
'1970-01-01T00:00:00.000Z',
|
|
1327
|
+
chatName,
|
|
1328
|
+
'web',
|
|
1329
|
+
false,
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Remove web-session symlinks in GROUPS_DIR and DATA_DIR/sessions that no
|
|
1335
|
+
* longer have a corresponding entry in the chats DB table.
|
|
1336
|
+
* Runs once at startup so orphans from deleted/expired sessions don't pile up.
|
|
1337
|
+
*/
|
|
1338
|
+
private cleanupOrphanedSessionLinks(): void {
|
|
1339
|
+
const known = new Set(
|
|
1340
|
+
getAllChats()
|
|
1341
|
+
.map((c) => c.jid)
|
|
1342
|
+
.filter(
|
|
1343
|
+
(j) =>
|
|
1344
|
+
j.startsWith(WEB_JID_PREFIX) &&
|
|
1345
|
+
j !== WEB_JID_PREFIX + CRON_SESSION_ID,
|
|
1346
|
+
)
|
|
1347
|
+
.map((j) => j.slice(WEB_JID_PREFIX.length)),
|
|
1348
|
+
);
|
|
1349
|
+
|
|
1350
|
+
for (const dir of [GROUPS_DIR, path.join(DATA_DIR, 'sessions')]) {
|
|
1351
|
+
let entries: string[];
|
|
1352
|
+
try {
|
|
1353
|
+
entries = fs.readdirSync(dir);
|
|
1354
|
+
} catch {
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
for (const entry of entries) {
|
|
1358
|
+
if (!entry.startsWith('web-')) continue;
|
|
1359
|
+
const sid = entry.slice('web-'.length);
|
|
1360
|
+
if (!known.has(sid)) {
|
|
1361
|
+
try {
|
|
1362
|
+
fs.unlinkSync(path.join(dir, entry));
|
|
1363
|
+
logger.debug({ entry, dir }, 'Removed orphaned session symlink');
|
|
1364
|
+
} catch {
|
|
1365
|
+
/* ignore — may already be gone */
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async connect(): Promise<void> {
|
|
1373
|
+
this.server = http.createServer((req, res) => {
|
|
1374
|
+
// ── Token-based access control ──────────────────────────────────────────
|
|
1375
|
+
// authorizeRequest() returns false and sends 401 if the token is wrong.
|
|
1376
|
+
// If the token arrived via ?token= it also sets a session cookie so that
|
|
1377
|
+
// subsequent requests (SSE, API calls, asset loads) pass without repeating it.
|
|
1378
|
+
if (!authorizeRequest(req, res)) return;
|
|
1379
|
+
|
|
1380
|
+
if (req.method === 'GET' && req.url?.split('?')[0] === '/') {
|
|
1381
|
+
const html = HTML.replaceAll(
|
|
1382
|
+
'__SERVER_ADDRESS__',
|
|
1383
|
+
`${getLocalIp()}:${PORT}`,
|
|
1384
|
+
);
|
|
1385
|
+
res.writeHead(200, {
|
|
1386
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1387
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
1388
|
+
Pragma: 'no-cache',
|
|
1389
|
+
Expires: '0',
|
|
1390
|
+
});
|
|
1391
|
+
res.end(html);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// GET /sessions — list all web sessions known to the server (from DB)
|
|
1396
|
+
// Response: { sessions: Session[], order: string[] }
|
|
1397
|
+
// order contains session IDs in the user-defined drag order (empty = no custom order).
|
|
1398
|
+
if (req.method === 'GET' && req.url === '/sessions') {
|
|
1399
|
+
const chats = getAllChats();
|
|
1400
|
+
const sessions = chats
|
|
1401
|
+
.filter((c) => c.jid.startsWith(WEB_JID_PREFIX))
|
|
1402
|
+
.map((c) => {
|
|
1403
|
+
const id = c.jid.slice(WEB_JID_PREFIX.length);
|
|
1404
|
+
return {
|
|
1405
|
+
id,
|
|
1406
|
+
name: c.name,
|
|
1407
|
+
lastMessage: c.last_message_time,
|
|
1408
|
+
nameUpdatedAt: c.name_updated_at || 0,
|
|
1409
|
+
cwd: sessionCwds.get(id) || c.cwd || '',
|
|
1410
|
+
};
|
|
1411
|
+
});
|
|
1412
|
+
// Strip WEB_JID_PREFIX from stored JIDs so clients see plain session IDs
|
|
1413
|
+
const rawOrder = getWebSessionOrder();
|
|
1414
|
+
const order = rawOrder.map((jid) =>
|
|
1415
|
+
jid.startsWith(WEB_JID_PREFIX)
|
|
1416
|
+
? jid.slice(WEB_JID_PREFIX.length)
|
|
1417
|
+
: jid,
|
|
1418
|
+
);
|
|
1419
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1420
|
+
res.end(JSON.stringify({ sessions, order }));
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// POST /session-order — persist the user-defined session display order
|
|
1425
|
+
if (req.method === 'POST' && req.url === '/session-order') {
|
|
1426
|
+
collectBody(req, res, (body) => {
|
|
1427
|
+
try {
|
|
1428
|
+
const { order } = JSON.parse(body);
|
|
1429
|
+
if (
|
|
1430
|
+
!Array.isArray(order) ||
|
|
1431
|
+
!order.every((id) => typeof id === 'string')
|
|
1432
|
+
) {
|
|
1433
|
+
throw new Error('order must be an array of strings');
|
|
1434
|
+
}
|
|
1435
|
+
// Store as full JIDs internally
|
|
1436
|
+
setWebSessionOrder(order.map((id) => WEB_JID_PREFIX + id));
|
|
1437
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1438
|
+
res.end('{"ok":true}');
|
|
1439
|
+
} catch {
|
|
1440
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1441
|
+
res.end('{"error":"Bad request"}');
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// POST /session-name — persist a custom session name to the DB
|
|
1448
|
+
if (req.method === 'POST' && req.url === '/session-name') {
|
|
1449
|
+
collectBody(req, res, (body) => {
|
|
1450
|
+
try {
|
|
1451
|
+
const { sid, name, nameUpdatedAt } = JSON.parse(body);
|
|
1452
|
+
if (sid && name && typeof name === 'string') {
|
|
1453
|
+
const safeName = sanitizeSessionName(name);
|
|
1454
|
+
if (safeName) {
|
|
1455
|
+
// Server-side guard: updateChatName only writes if nameUpdatedAt >= stored value
|
|
1456
|
+
const ts =
|
|
1457
|
+
typeof nameUpdatedAt === 'number'
|
|
1458
|
+
? nameUpdatedAt
|
|
1459
|
+
: Date.now();
|
|
1460
|
+
updateChatName(WEB_JID_PREFIX + sid, safeName, ts);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1464
|
+
res.end('{"ok":true}');
|
|
1465
|
+
} catch {
|
|
1466
|
+
res.writeHead(400);
|
|
1467
|
+
res.end('Bad request');
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// POST /delete-session — remove a session and its messages from the DB
|
|
1474
|
+
if (req.method === 'POST' && req.url === '/delete-session') {
|
|
1475
|
+
collectBody(req, res, (body) => {
|
|
1476
|
+
try {
|
|
1477
|
+
const { sid } = JSON.parse(body);
|
|
1478
|
+
if (sid && typeof sid === 'string') {
|
|
1479
|
+
deleteChat(WEB_JID_PREFIX + sid);
|
|
1480
|
+
registeredSessions.delete(sid);
|
|
1481
|
+
sseClients.delete(sid);
|
|
1482
|
+
sessionCwds.delete(sid);
|
|
1483
|
+
sessionTyping.delete(sid);
|
|
1484
|
+
sessionStatus.delete(sid);
|
|
1485
|
+
// Clean up per-session symlinks (user sessions only; cron has real dirs)
|
|
1486
|
+
if (sid !== CRON_SESSION_ID) {
|
|
1487
|
+
const folder = 'web-' + sid;
|
|
1488
|
+
try {
|
|
1489
|
+
fs.unlinkSync(path.join(GROUPS_DIR, folder));
|
|
1490
|
+
} catch {
|
|
1491
|
+
/* ignore */
|
|
1492
|
+
}
|
|
1493
|
+
try {
|
|
1494
|
+
fs.unlinkSync(path.join(DATA_DIR, 'sessions', folder));
|
|
1495
|
+
} catch {
|
|
1496
|
+
/* ignore */
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1501
|
+
res.end('{"ok":true}');
|
|
1502
|
+
} catch {
|
|
1503
|
+
res.writeHead(400);
|
|
1504
|
+
res.end('Bad request');
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// POST /delete-message — delete a single message by id
|
|
1511
|
+
if (req.method === 'POST' && req.url === '/delete-message') {
|
|
1512
|
+
collectBody(req, res, (body) => {
|
|
1513
|
+
try {
|
|
1514
|
+
const { id } = JSON.parse(body);
|
|
1515
|
+
if (id && typeof id === 'string') {
|
|
1516
|
+
deleteMessage(id);
|
|
1517
|
+
}
|
|
1518
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1519
|
+
res.end('{"ok":true}');
|
|
1520
|
+
} catch {
|
|
1521
|
+
res.writeHead(400);
|
|
1522
|
+
res.end('Bad request');
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// POST /clear-history — delete all messages for a session (keeps session entry)
|
|
1529
|
+
if (req.method === 'POST' && req.url === '/clear-history') {
|
|
1530
|
+
collectBody(req, res, (body) => {
|
|
1531
|
+
try {
|
|
1532
|
+
const { sid } = JSON.parse(body);
|
|
1533
|
+
if (sid && typeof sid === 'string') {
|
|
1534
|
+
clearChatMessages(WEB_JID_PREFIX + sid);
|
|
1535
|
+
}
|
|
1536
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1537
|
+
res.end('{"ok":true}');
|
|
1538
|
+
} catch {
|
|
1539
|
+
res.writeHead(400);
|
|
1540
|
+
res.end('Bad request');
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// GET /history?sid=... — full conversation for a session (user + bot)
|
|
1547
|
+
if (req.method === 'GET' && req.url?.startsWith('/history')) {
|
|
1548
|
+
const sid = sidFromUrl(req.url);
|
|
1549
|
+
const jid = WEB_JID_PREFIX + sid;
|
|
1550
|
+
const messages = getConversation(jid, 500);
|
|
1551
|
+
const history = messages.map((m) => ({
|
|
1552
|
+
text: m.content,
|
|
1553
|
+
cls: m.is_bot_message || m.is_from_me ? 'bot' : 'user',
|
|
1554
|
+
id: m.id,
|
|
1555
|
+
}));
|
|
1556
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1557
|
+
res.end(JSON.stringify(history));
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (req.method === 'GET' && req.url?.startsWith('/events')) {
|
|
1562
|
+
const sessionId = sidFromUrl(req.url);
|
|
1563
|
+
// Set up SSE connection first, then register session (avoids interrupting the stream)
|
|
1564
|
+
res.writeHead(200, {
|
|
1565
|
+
'Content-Type': 'text/event-stream',
|
|
1566
|
+
'Cache-Control': 'no-cache',
|
|
1567
|
+
Connection: 'keep-alive',
|
|
1568
|
+
});
|
|
1569
|
+
res.write(':\n\n');
|
|
1570
|
+
const clients = getOrCreateClientSet(sessionId);
|
|
1571
|
+
clients.add(res);
|
|
1572
|
+
// Restore CWD from DB if not in memory (e.g. after server restart)
|
|
1573
|
+
if (!sessionCwds.has(sessionId)) {
|
|
1574
|
+
const chat = getAllChats().find(
|
|
1575
|
+
(c) => c.jid === WEB_JID_PREFIX + sessionId,
|
|
1576
|
+
);
|
|
1577
|
+
if (chat?.cwd) sessionCwds.set(sessionId, chat.cwd);
|
|
1578
|
+
}
|
|
1579
|
+
const cwd = sessionCwds.get(sessionId);
|
|
1580
|
+
if (cwd) res.write(`event: cwd\ndata: ${JSON.stringify(cwd)}\n\n`);
|
|
1581
|
+
// Always send current typing/status state so client syncs correctly on reconnect
|
|
1582
|
+
res.write(
|
|
1583
|
+
`event: typing\ndata: ${sessionTyping.get(sessionId) ? 'true' : 'false'}\n\n`,
|
|
1584
|
+
);
|
|
1585
|
+
const status = sessionStatus.get(sessionId);
|
|
1586
|
+
res.write(`event: status\ndata: ${status ?? 'null'}\n\n`);
|
|
1587
|
+
req.on('close', () => clients.delete(res));
|
|
1588
|
+
// Register in DB after SSE is established — so other browsers can discover this session
|
|
1589
|
+
try {
|
|
1590
|
+
this.ensureSession(sessionId);
|
|
1591
|
+
} catch {}
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (req.method === 'POST' && req.url?.startsWith('/cancel')) {
|
|
1596
|
+
const sid = sidFromUrl(req.url);
|
|
1597
|
+
if (sid && this.onCancelRequest) {
|
|
1598
|
+
this.onCancelRequest(WEB_JID_PREFIX + sid);
|
|
1599
|
+
}
|
|
1600
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1601
|
+
res.end('{"ok":true}');
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if (req.method === 'POST' && req.url === '/message') {
|
|
1606
|
+
collectBody(req, res, (body) => {
|
|
1607
|
+
try {
|
|
1608
|
+
const {
|
|
1609
|
+
content,
|
|
1610
|
+
sessionId = 'default',
|
|
1611
|
+
id: clientMsgId,
|
|
1612
|
+
} = JSON.parse(body);
|
|
1613
|
+
if (content && typeof content === 'string') {
|
|
1614
|
+
this.ensureSession(sessionId);
|
|
1615
|
+
const jid = WEB_JID_PREFIX + sessionId;
|
|
1616
|
+
|
|
1617
|
+
const cwdMatch = content.match(/^switching to topic:\s*(.+)$/i);
|
|
1618
|
+
if (cwdMatch) {
|
|
1619
|
+
const cwd = 'Topics/' + cwdMatch[1].trim();
|
|
1620
|
+
setCwd(sessionId, cwd);
|
|
1621
|
+
broadcastToSession(sessionId, 'cwd', JSON.stringify(cwd));
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Use client-provided ID if valid (allows delete button to work immediately),
|
|
1625
|
+
// otherwise generate one server-side.
|
|
1626
|
+
const msgId =
|
|
1627
|
+
typeof clientMsgId === 'string' &&
|
|
1628
|
+
/^[\w-]{1,80}$/.test(clientMsgId)
|
|
1629
|
+
? clientMsgId
|
|
1630
|
+
: `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1631
|
+
const msg: NewMessage = {
|
|
1632
|
+
id: msgId,
|
|
1633
|
+
chat_jid: jid,
|
|
1634
|
+
sender: 'user@web',
|
|
1635
|
+
sender_name: 'User',
|
|
1636
|
+
content: content.trim(),
|
|
1637
|
+
timestamp: new Date().toISOString(),
|
|
1638
|
+
is_from_me: false,
|
|
1639
|
+
is_bot_message: false,
|
|
1640
|
+
};
|
|
1641
|
+
try {
|
|
1642
|
+
storeMessage(msg); // persist to DB for cross-browser history
|
|
1643
|
+
storeChatMetadata(jid, msg.timestamp); // keep chats.last_message_time current for unread detection
|
|
1644
|
+
} catch {}
|
|
1645
|
+
this.onMessage(jid, msg);
|
|
1646
|
+
}
|
|
1647
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1648
|
+
res.end('{"ok":true}');
|
|
1649
|
+
} catch {
|
|
1650
|
+
res.writeHead(400);
|
|
1651
|
+
res.end('Bad request');
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
if (req.method === 'POST' && req.url === '/cwd') {
|
|
1658
|
+
collectBody(req, res, (body) => {
|
|
1659
|
+
try {
|
|
1660
|
+
const { cwd, sessionId = 'default' } = JSON.parse(body);
|
|
1661
|
+
if (typeof cwd === 'string') {
|
|
1662
|
+
// Validate that the resolved path stays within the workspace
|
|
1663
|
+
const workspace = process.cwd();
|
|
1664
|
+
const resolved = path.resolve(workspace, cwd);
|
|
1665
|
+
if (
|
|
1666
|
+
resolved !== workspace &&
|
|
1667
|
+
!resolved.startsWith(workspace + path.sep)
|
|
1668
|
+
) {
|
|
1669
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1670
|
+
res.end('{"error":"CWD outside workspace"}');
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
const safeCwd = path.relative(workspace, resolved);
|
|
1674
|
+
setCwd(sessionId, safeCwd);
|
|
1675
|
+
broadcastToSession(sessionId, 'cwd', JSON.stringify(safeCwd));
|
|
1676
|
+
}
|
|
1677
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1678
|
+
res.end('{"ok":true}');
|
|
1679
|
+
} catch {
|
|
1680
|
+
res.writeHead(400);
|
|
1681
|
+
res.end('Bad request');
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
if (req.method === 'POST' && req.url === '/upload') {
|
|
1688
|
+
// File data is Base64-encoded (~33 % overhead → ~7.5 MB effective file size).
|
|
1689
|
+
collectBody(
|
|
1690
|
+
req,
|
|
1691
|
+
res,
|
|
1692
|
+
(body) => {
|
|
1693
|
+
try {
|
|
1694
|
+
const {
|
|
1695
|
+
filename,
|
|
1696
|
+
data,
|
|
1697
|
+
sessionId = 'default',
|
|
1698
|
+
} = JSON.parse(body);
|
|
1699
|
+
if (!filename || !data) throw new Error('Missing fields');
|
|
1700
|
+
const safeName = path.basename(filename);
|
|
1701
|
+
const groupDir = path.join(process.cwd(), 'groups', GROUP_FOLDER);
|
|
1702
|
+
const cwd = sessionCwds.get(sessionId) ?? '';
|
|
1703
|
+
const dir = cwd ? path.join(groupDir, cwd) : groupDir;
|
|
1704
|
+
// Verify upload directory stays within groupDir (defense-in-depth)
|
|
1705
|
+
if (dir !== groupDir && !dir.startsWith(groupDir + path.sep)) {
|
|
1706
|
+
throw new Error('Upload path outside workspace');
|
|
1707
|
+
}
|
|
1708
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1709
|
+
const filePath = path.join(dir, safeName);
|
|
1710
|
+
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
|
|
1711
|
+
const relPath = path.relative(groupDir, filePath);
|
|
1712
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1713
|
+
res.end(JSON.stringify({ ok: true, path: relPath }));
|
|
1714
|
+
} catch (e: any) {
|
|
1715
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1716
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
1717
|
+
}
|
|
1718
|
+
},
|
|
1719
|
+
MAX_UPLOAD_BODY_SIZE,
|
|
1720
|
+
);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Static assets: favicon.ico and apple-touch-icon.png from group folder
|
|
1725
|
+
const staticFiles: Record<string, string> = {
|
|
1726
|
+
'/favicon.ico': 'image/x-icon',
|
|
1727
|
+
'/favicon.png': 'image/png',
|
|
1728
|
+
'/apple-touch-icon.png': 'image/png',
|
|
1729
|
+
};
|
|
1730
|
+
const urlPath = req.url?.split('?')[0] ?? '';
|
|
1731
|
+
if (req.method === 'GET' && staticFiles[urlPath]) {
|
|
1732
|
+
const filePath = path.join(
|
|
1733
|
+
process.cwd(),
|
|
1734
|
+
'groups',
|
|
1735
|
+
GROUP_FOLDER,
|
|
1736
|
+
urlPath.slice(1),
|
|
1737
|
+
);
|
|
1738
|
+
try {
|
|
1739
|
+
const data = fs.readFileSync(filePath);
|
|
1740
|
+
res.writeHead(200, {
|
|
1741
|
+
'Content-Type': staticFiles[urlPath],
|
|
1742
|
+
'Cache-Control': 'max-age=3600',
|
|
1743
|
+
});
|
|
1744
|
+
res.end(data);
|
|
1745
|
+
} catch {
|
|
1746
|
+
res.writeHead(404);
|
|
1747
|
+
res.end('Not found');
|
|
1748
|
+
}
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
res.writeHead(404);
|
|
1753
|
+
res.end('Not found');
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
await new Promise<void>((resolve, reject) => {
|
|
1757
|
+
this.server!.listen(PORT, HOST, () => resolve());
|
|
1758
|
+
this.server!.on('error', reject);
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
this.connected = true;
|
|
1762
|
+
logger.info({ port: PORT }, 'Web chat channel listening');
|
|
1763
|
+
|
|
1764
|
+
// Ensure dedicated cron session exists (low timestamp so user renames always win)
|
|
1765
|
+
try {
|
|
1766
|
+
updateChatName(WEB_JID_PREFIX + CRON_SESSION_ID, CRON_SESSION_NAME, 1);
|
|
1767
|
+
this.ensureSession(CRON_SESSION_ID);
|
|
1768
|
+
} catch {}
|
|
1769
|
+
|
|
1770
|
+
// Remove symlinks for sessions that no longer exist in the DB
|
|
1771
|
+
try {
|
|
1772
|
+
this.cleanupOrphanedSessionLinks();
|
|
1773
|
+
} catch {
|
|
1774
|
+
/* non-critical */
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
async sendMessage(jid: string, text: string): Promise<void> {
|
|
1779
|
+
const sessionId = sessionIdFromJid(jid);
|
|
1780
|
+
const topicMatch = text.match(/^switching to topic:\s*(\S+)/im);
|
|
1781
|
+
if (topicMatch) {
|
|
1782
|
+
const cwd = 'Topics/' + topicMatch[1].trim();
|
|
1783
|
+
setCwd(sessionId, cwd);
|
|
1784
|
+
broadcastToSession(sessionId, 'cwd', JSON.stringify(cwd));
|
|
1785
|
+
}
|
|
1786
|
+
// Persist bot response to DB so history works across browsers/windows
|
|
1787
|
+
const botTs = new Date().toISOString();
|
|
1788
|
+
const botMsgId = `web-bot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1789
|
+
try {
|
|
1790
|
+
storeMessage({
|
|
1791
|
+
id: botMsgId,
|
|
1792
|
+
chat_jid: jid,
|
|
1793
|
+
sender: 'bot@web',
|
|
1794
|
+
sender_name: 'Nemo',
|
|
1795
|
+
content: text,
|
|
1796
|
+
timestamp: botTs,
|
|
1797
|
+
is_from_me: true,
|
|
1798
|
+
is_bot_message: true,
|
|
1799
|
+
});
|
|
1800
|
+
// storeMessage only writes to the messages table; update chats.last_message_time
|
|
1801
|
+
// so that mergeServerSessions() can detect this session as unread in other browsers/tabs.
|
|
1802
|
+
storeChatMetadata(jid, botTs);
|
|
1803
|
+
} catch {}
|
|
1804
|
+
// Broadcast {text, id} so the client can assign a trash button with the correct DB id
|
|
1805
|
+
broadcastToSession(
|
|
1806
|
+
sessionId,
|
|
1807
|
+
'message',
|
|
1808
|
+
JSON.stringify({ text, id: botMsgId }),
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
|
1813
|
+
const sessionId = sessionIdFromJid(jid);
|
|
1814
|
+
if (isTyping) {
|
|
1815
|
+
sessionTyping.set(sessionId, true);
|
|
1816
|
+
} else {
|
|
1817
|
+
sessionTyping.delete(sessionId);
|
|
1818
|
+
// Also clear status so reconnecting clients don't see a stale tool-use line
|
|
1819
|
+
sessionStatus.delete(sessionId);
|
|
1820
|
+
broadcastToSession(sessionId, 'status', 'null');
|
|
1821
|
+
}
|
|
1822
|
+
broadcastToSession(sessionId, 'typing', String(isTyping));
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
setStatus(jid: string, tool: string | null, inputSnippet?: string): void {
|
|
1826
|
+
const sessionId = sessionIdFromJid(jid);
|
|
1827
|
+
const payload = tool
|
|
1828
|
+
? JSON.stringify({ tool, input: inputSnippet ?? null })
|
|
1829
|
+
: 'null';
|
|
1830
|
+
if (tool) sessionStatus.set(sessionId, payload);
|
|
1831
|
+
else sessionStatus.delete(sessionId);
|
|
1832
|
+
broadcastToSession(sessionId, 'status', payload);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
isConnected(): boolean {
|
|
1836
|
+
return this.connected;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
ownsJid(jid: string): boolean {
|
|
1840
|
+
return jid.startsWith(WEB_JID_PREFIX);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
async disconnect(): Promise<void> {
|
|
1844
|
+
this.connected = false;
|
|
1845
|
+
for (const clients of sseClients.values()) {
|
|
1846
|
+
for (const client of clients) client.end();
|
|
1847
|
+
}
|
|
1848
|
+
sseClients.clear();
|
|
1849
|
+
await new Promise<void>((resolve) => {
|
|
1850
|
+
if (this.server) this.server.close(() => resolve());
|
|
1851
|
+
else resolve();
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
registerChannel('web', (opts) => new WebChannel(opts));
|