@mooncompany/uplink-chat 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @mooncompany/uplink-chat might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- package/utils/with-retry.js +105 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// MARKDOWN MODULE
|
|
3
|
+
// Enhanced markdown rendering for messages
|
|
4
|
+
// ============================================
|
|
5
|
+
|
|
6
|
+
// Validate URL to prevent XSS via javascript: protocol
|
|
7
|
+
function isValidHttpUrl(str) {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(str);
|
|
10
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Markdown rendering function
|
|
17
|
+
export function render(text) {
|
|
18
|
+
if (!text) return '';
|
|
19
|
+
|
|
20
|
+
// Escape HTML first
|
|
21
|
+
let html = text
|
|
22
|
+
.replace(/&/g, '&')
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>');
|
|
25
|
+
|
|
26
|
+
// Code blocks (```language\ncode\n```)
|
|
27
|
+
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
|
|
28
|
+
const langClass = lang ? ` class="language-${lang}"` : '';
|
|
29
|
+
return `<pre><code${langClass}>${code.trim()}</code></pre>`;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Inline code (`code`)
|
|
33
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
34
|
+
|
|
35
|
+
// Tables (GitHub-flavored markdown) - MUST be before line breaks
|
|
36
|
+
html = html.replace(/\|([^\n]+)\|\n\|[-:\|\s]+\|\n((?:\|[^\n]+\|\n?)+)/g, (match, headerRow, bodyRows) => {
|
|
37
|
+
const headers = headerRow.split('|').map(h => h.trim()).filter(h => h);
|
|
38
|
+
const rows = bodyRows.trim().split('\n').map(row => {
|
|
39
|
+
const cells = row.split('|').map(c => c.trim()).filter(c => c);
|
|
40
|
+
return `<tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr>`;
|
|
41
|
+
}).join('');
|
|
42
|
+
return `<table class="md-table"><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Headers (# ## ###)
|
|
46
|
+
html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
|
|
47
|
+
html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
|
|
48
|
+
html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
|
|
49
|
+
|
|
50
|
+
// Bold (**text** or __text__)
|
|
51
|
+
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
52
|
+
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
|
53
|
+
|
|
54
|
+
// Italic (*text* or _text_)
|
|
55
|
+
html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/gm, '$1<em>$2</em>');
|
|
56
|
+
html = html.replace(/(^|[^_])_([^_]+)_(?!_)/gm, '$1<em>$2</em>');
|
|
57
|
+
|
|
58
|
+
// Strikethrough (~~text~~)
|
|
59
|
+
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
|
60
|
+
|
|
61
|
+
// Blockquotes (> text)
|
|
62
|
+
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
|
63
|
+
// Merge consecutive blockquotes
|
|
64
|
+
html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
65
|
+
|
|
66
|
+
// Horizontal rules (--- or ***)
|
|
67
|
+
html = html.replace(/^(-{3,}|\*{3,})$/gm, '<hr>');
|
|
68
|
+
|
|
69
|
+
// Unordered lists (- item or * item)
|
|
70
|
+
html = html.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>');
|
|
71
|
+
html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
|
|
72
|
+
|
|
73
|
+
// Ordered lists (1. item)
|
|
74
|
+
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
|
|
75
|
+
// Wrap consecutive li's in ol (avoiding ul)
|
|
76
|
+
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match, _p1, offset) => {
|
|
77
|
+
const before = html.substring(Math.max(0, offset - 5), offset);
|
|
78
|
+
if (before.includes('<ul>') || match.includes('<ul>')) return match;
|
|
79
|
+
return '<ol>' + match + '</ol>';
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Images  - process before links to avoid conflict
|
|
83
|
+
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
|
|
84
|
+
const trimmedUrl = url.trim();
|
|
85
|
+
|
|
86
|
+
// Rewrite local file paths to agent media proxy
|
|
87
|
+
// Matches Windows paths (C:\...), Unix paths (/home/...), and relative paths
|
|
88
|
+
if (!/^(https?:|\/api\/)/i.test(trimmedUrl)) {
|
|
89
|
+
// This is a local path - it should have been converted to a proxy URL by the server
|
|
90
|
+
// If it wasn't, skip rendering to avoid broken images
|
|
91
|
+
console.warn('[Markdown] Local file path in markdown image, skipping:', trimmedUrl);
|
|
92
|
+
return `[Image: ${alt || 'local file'}]`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return `<img src="${trimmedUrl}" alt="${alt || ''}" loading="lazy">`;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Links [text](url)
|
|
99
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
|
|
100
|
+
const trimmedUrl = url.trim();
|
|
101
|
+
if (/^(https?:|mailto:|\/|#)/i.test(trimmedUrl)) {
|
|
102
|
+
return `<a href="${trimmedUrl}" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
103
|
+
}
|
|
104
|
+
return text;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Auto-link URLs
|
|
108
|
+
html = html.replace(/(^|[\s>])(https?:\/\/[^\s<]+)/g,
|
|
109
|
+
(match, prefix, url) => {
|
|
110
|
+
if (prefix === '"' || prefix === "'") return match;
|
|
111
|
+
return isValidHttpUrl(url)
|
|
112
|
+
? `${prefix}<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
|
|
113
|
+
: match;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Artifact deep links — detect artifacts/filename references and make them clickable
|
|
117
|
+
// Uses data-artifact attribute + event delegation (no inline onclick — works on mobile/PWA)
|
|
118
|
+
html = html.replace(/<code>artifacts\/([a-zA-Z0-9_\-]+\.[a-zA-Z]+)<\/code>/g,
|
|
119
|
+
'<a href="javascript:void(0)" class="artifact-link" data-artifact="$1">📄 $1</a>');
|
|
120
|
+
html = html.replace(/(?<!<code>|data-artifact=")artifacts\/([a-zA-Z0-9_\-]+\.[a-zA-Z]+)/g,
|
|
121
|
+
'<a href="javascript:void(0)" class="artifact-link" data-artifact="$1">📄 $1</a>');
|
|
122
|
+
|
|
123
|
+
// Line breaks
|
|
124
|
+
html = html.replace(/\n/g, '<br>');
|
|
125
|
+
|
|
126
|
+
// Clean up extra breaks around block elements
|
|
127
|
+
html = html.replace(/<br>(<\/?(?:pre|ul|ol|li|blockquote|h[2-4]|hr|table))/g, '$1');
|
|
128
|
+
html = html.replace(/(<\/(?:pre|ul|ol|li|blockquote|h[2-4])>)<br>/g, '$1');
|
|
129
|
+
|
|
130
|
+
return html;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Apply syntax highlighting to code blocks (basic)
|
|
134
|
+
export function highlightCode(container) {
|
|
135
|
+
container.querySelectorAll('pre code').forEach(block => {
|
|
136
|
+
// Skip already-highlighted blocks
|
|
137
|
+
if (block.dataset.highlighted) return;
|
|
138
|
+
|
|
139
|
+
const lang = block.className.replace('language-', '');
|
|
140
|
+
if (!lang) return;
|
|
141
|
+
|
|
142
|
+
const keywords = {
|
|
143
|
+
js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'class', 'import', 'export', 'from', 'async', 'await', 'try', 'catch', 'throw', 'new', 'this', 'true', 'false', 'null', 'undefined'],
|
|
144
|
+
python: ['def', 'class', 'if', 'elif', 'else', 'for', 'while', 'return', 'import', 'from', 'as', 'try', 'except', 'raise', 'with', 'True', 'False', 'None', 'and', 'or', 'not', 'in', 'is', 'lambda', 'self'],
|
|
145
|
+
css: ['@media', '@import', '@keyframes', '!important'],
|
|
146
|
+
html: []
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const langKeywords = keywords[lang] || keywords.js;
|
|
150
|
+
if (langKeywords.length === 0) return;
|
|
151
|
+
|
|
152
|
+
let code = block.innerHTML;
|
|
153
|
+
|
|
154
|
+
// Highlight strings
|
|
155
|
+
code = code.replace(/(["'`])(?:(?!\1)[^\\]|\\.)*\1/g, '<span class="md-string">$&</span>');
|
|
156
|
+
|
|
157
|
+
// Highlight comments
|
|
158
|
+
code = code.replace(/(\/\/.*$|\/\*[\s\S]*?\*\/|#.*$)/gm, '<span class="md-comment">$&</span>');
|
|
159
|
+
|
|
160
|
+
// Highlight keywords (word boundary)
|
|
161
|
+
const keywordRegex = new RegExp(`\\b(${langKeywords.join('|')})\\b`, 'g');
|
|
162
|
+
code = code.replace(keywordRegex, '<span class="md-keyword">$1</span>');
|
|
163
|
+
|
|
164
|
+
// Highlight numbers
|
|
165
|
+
code = code.replace(/\b(\d+\.?\d*)\b/g, '<span class="md-number">$1</span>');
|
|
166
|
+
|
|
167
|
+
block.innerHTML = code;
|
|
168
|
+
block.dataset.highlighted = 'true';
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Delegated click handler for artifact deep links (works on mobile/PWA, no inline onclick needed)
|
|
173
|
+
document.addEventListener('click', (e) => {
|
|
174
|
+
const link = e.target.closest('.artifact-link[data-artifact]');
|
|
175
|
+
if (!link) return;
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
const filename = link.dataset.artifact;
|
|
178
|
+
if (filename && window.UplinkArtifacts?.openArtifactByName) {
|
|
179
|
+
window.UplinkArtifacts.openArtifactByName(filename);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Export API
|
|
184
|
+
export const UplinkMarkdown = {
|
|
185
|
+
render,
|
|
186
|
+
highlightCode
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Backward compat: assign to window
|
|
190
|
+
window.UplinkMarkdown = UplinkMarkdown;
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// MESSAGE ACTIONS MODULE
|
|
3
|
+
// Copy, Reply, Fork to Satellite
|
|
4
|
+
// ============================================
|
|
5
|
+
|
|
6
|
+
import { UplinkCore } from './core.js';
|
|
7
|
+
|
|
8
|
+
// ========== CONSTANTS (L-07: Extract magic numbers) ==========
|
|
9
|
+
const COPY_FEEDBACK_DURATION_MS = 1500; // How long to show "copied" state
|
|
10
|
+
const TEXT_TRUNCATE_REPLY_LENGTH = 150; // Reply context max chars
|
|
11
|
+
const TEXT_TRUNCATE_FORK_LENGTH = 500; // Fork dialog max chars
|
|
12
|
+
const SATELLITE_NAME_MAX_LENGTH = 32; // Max length for satellite name
|
|
13
|
+
|
|
14
|
+
// State
|
|
15
|
+
let replyingTo = null; // { text, type, element }
|
|
16
|
+
|
|
17
|
+
// Icons
|
|
18
|
+
const ICONS = {
|
|
19
|
+
copy: '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>',
|
|
20
|
+
reply: '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 17l-5-5 5-5"/><path d="M4 12h11a4 4 0 014 4v4"/></svg>',
|
|
21
|
+
fork: '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M6 9v6c0 1.1.9 2 2 2h5"/><path d="M18 9v0"/></svg>',
|
|
22
|
+
edit: '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create action buttons for a message
|
|
27
|
+
* @param {HTMLElement} msgElement - The message div
|
|
28
|
+
* @param {string} type - 'user' or 'assistant'
|
|
29
|
+
* @param {string} text - Message text content
|
|
30
|
+
*/
|
|
31
|
+
function createActions(msgElement, type, text) {
|
|
32
|
+
const actions = document.createElement('div');
|
|
33
|
+
actions.className = 'message-actions';
|
|
34
|
+
|
|
35
|
+
// Copy button (all messages)
|
|
36
|
+
const copyBtn = createButton('copy', 'Copy', () => copyMessage(text, copyBtn));
|
|
37
|
+
actions.appendChild(copyBtn);
|
|
38
|
+
|
|
39
|
+
// Reply button (all messages)
|
|
40
|
+
const replyBtn = createButton('reply', 'Reply', () => startReply(text, type, msgElement));
|
|
41
|
+
actions.appendChild(replyBtn);
|
|
42
|
+
|
|
43
|
+
// Fork button (all messages)
|
|
44
|
+
const forkBtn = createButton('fork', 'Fork to satellite', () => showForkDialog(msgElement, text));
|
|
45
|
+
actions.appendChild(forkBtn);
|
|
46
|
+
|
|
47
|
+
msgElement.appendChild(actions);
|
|
48
|
+
msgElement.style.position = 'relative'; // For absolute positioning of actions
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createButton(icon, title, onClick) {
|
|
52
|
+
const btn = document.createElement('button');
|
|
53
|
+
btn.className = 'message-action-btn';
|
|
54
|
+
btn.innerHTML = ICONS[icon];
|
|
55
|
+
btn.title = title;
|
|
56
|
+
btn.setAttribute('aria-label', title);
|
|
57
|
+
btn.setAttribute('type', 'button');
|
|
58
|
+
btn.onclick = (e) => {
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
onClick();
|
|
61
|
+
};
|
|
62
|
+
// Support Enter and Space for keyboard activation
|
|
63
|
+
btn.onkeydown = (e) => {
|
|
64
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
e.stopPropagation();
|
|
67
|
+
onClick();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
return btn;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================
|
|
74
|
+
// COPY
|
|
75
|
+
// ============================================
|
|
76
|
+
async function copyMessage(text, btn) {
|
|
77
|
+
try {
|
|
78
|
+
await navigator.clipboard.writeText(text);
|
|
79
|
+
btn.classList.add('copied');
|
|
80
|
+
setTimeout(() => btn.classList.remove('copied'), COPY_FEEDBACK_DURATION_MS);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (window.UplinkLogger?.error) {
|
|
83
|
+
window.UplinkLogger.error('Failed to copy:', err);
|
|
84
|
+
}
|
|
85
|
+
// Fallback
|
|
86
|
+
const textarea = document.createElement('textarea');
|
|
87
|
+
textarea.value = text;
|
|
88
|
+
document.body.appendChild(textarea);
|
|
89
|
+
textarea.select();
|
|
90
|
+
document.execCommand('copy');
|
|
91
|
+
document.body.removeChild(textarea);
|
|
92
|
+
btn.classList.add('copied');
|
|
93
|
+
setTimeout(() => btn.classList.remove('copied'), COPY_FEEDBACK_DURATION_MS);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================
|
|
98
|
+
// REPLY
|
|
99
|
+
// ============================================
|
|
100
|
+
function startReply(text, type, element) {
|
|
101
|
+
// Clear any existing reply
|
|
102
|
+
clearReply();
|
|
103
|
+
|
|
104
|
+
replyingTo = { text, type, element };
|
|
105
|
+
|
|
106
|
+
// Highlight the message being replied to
|
|
107
|
+
element.classList.add('replying-to');
|
|
108
|
+
|
|
109
|
+
// Show reply context in input area
|
|
110
|
+
showReplyContext(text, type);
|
|
111
|
+
|
|
112
|
+
// Focus the input
|
|
113
|
+
const input = document.getElementById('textInput');
|
|
114
|
+
if (input) input.focus();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function showReplyContext(text, type) {
|
|
118
|
+
const inputArea = document.querySelector('.input-area');
|
|
119
|
+
if (!inputArea) return;
|
|
120
|
+
|
|
121
|
+
// Remove existing context
|
|
122
|
+
const existing = inputArea.querySelector('.reply-context');
|
|
123
|
+
if (existing) existing.remove();
|
|
124
|
+
|
|
125
|
+
// Create context element
|
|
126
|
+
const context = document.createElement('div');
|
|
127
|
+
context.className = 'reply-context';
|
|
128
|
+
|
|
129
|
+
const truncated = text.length > TEXT_TRUNCATE_REPLY_LENGTH ? text.substring(0, TEXT_TRUNCATE_REPLY_LENGTH) + '...' : text;
|
|
130
|
+
const sender = type === 'user' ? 'You' : (window.UplinkCore?.config?.agentName || 'Assistant');
|
|
131
|
+
|
|
132
|
+
context.innerHTML = `
|
|
133
|
+
<div class="reply-context-text">
|
|
134
|
+
<strong>${escapeHtml(sender)}:</strong> ${escapeHtml(truncated)}
|
|
135
|
+
</div>
|
|
136
|
+
<button class="reply-context-close" title="Cancel reply">×</button>
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
context.querySelector('.reply-context-close').onclick = clearReply;
|
|
140
|
+
|
|
141
|
+
// Insert before the text input row
|
|
142
|
+
const textRow = inputArea.querySelector('.text-input-row');
|
|
143
|
+
if (textRow) {
|
|
144
|
+
inputArea.insertBefore(context, textRow);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function clearReply() {
|
|
149
|
+
if (replyingTo?.element) {
|
|
150
|
+
replyingTo.element.classList.remove('replying-to');
|
|
151
|
+
}
|
|
152
|
+
replyingTo = null;
|
|
153
|
+
|
|
154
|
+
// Remove context display
|
|
155
|
+
const context = document.querySelector('.reply-context');
|
|
156
|
+
if (context) context.remove();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get reply context for sending with message
|
|
161
|
+
* Returns null if not replying, or { text, type } if replying
|
|
162
|
+
*/
|
|
163
|
+
function getReplyContext() {
|
|
164
|
+
if (!replyingTo) return null;
|
|
165
|
+
return { text: replyingTo.text, type: replyingTo.type };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Format message with reply context for sending
|
|
170
|
+
* @param {string} newMessage - The new message to send
|
|
171
|
+
* @returns {string} - Message formatted with reply context
|
|
172
|
+
*/
|
|
173
|
+
function formatMessageWithReply(newMessage) {
|
|
174
|
+
const ctx = getReplyContext();
|
|
175
|
+
if (!ctx) return newMessage;
|
|
176
|
+
|
|
177
|
+
// Clear the reply state
|
|
178
|
+
clearReply();
|
|
179
|
+
|
|
180
|
+
// Format: include the quoted message as context
|
|
181
|
+
const sender = ctx.type === 'user' ? 'User' : 'Assistant';
|
|
182
|
+
const truncated = ctx.text.length > TEXT_TRUNCATE_FORK_LENGTH ? ctx.text.substring(0, TEXT_TRUNCATE_FORK_LENGTH) + '...' : ctx.text;
|
|
183
|
+
|
|
184
|
+
return `[Replying to ${sender}: "${truncated}"]\n\n${newMessage}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================
|
|
188
|
+
// FORK TO SATELLITE
|
|
189
|
+
// ============================================
|
|
190
|
+
function showForkDialog(msgElement, text) {
|
|
191
|
+
// Store the trigger element to return focus when closed
|
|
192
|
+
const triggerElement = document.activeElement;
|
|
193
|
+
|
|
194
|
+
// Remove existing dialog
|
|
195
|
+
const existing = document.querySelector('.fork-dialog-backdrop');
|
|
196
|
+
if (existing) existing.remove();
|
|
197
|
+
|
|
198
|
+
const truncated = text.length > TEXT_TRUNCATE_FORK_LENGTH ? text.substring(0, TEXT_TRUNCATE_FORK_LENGTH) + '...' : text;
|
|
199
|
+
|
|
200
|
+
// Create backdrop
|
|
201
|
+
const backdrop = document.createElement('div');
|
|
202
|
+
backdrop.className = 'fork-dialog-backdrop';
|
|
203
|
+
backdrop.setAttribute('role', 'presentation');
|
|
204
|
+
|
|
205
|
+
// Function to close dialog and restore focus
|
|
206
|
+
const closeDialog = () => {
|
|
207
|
+
backdrop.remove();
|
|
208
|
+
// Return focus to trigger element
|
|
209
|
+
if (triggerElement && triggerElement.focus) {
|
|
210
|
+
triggerElement.focus();
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
backdrop.onclick = closeDialog;
|
|
215
|
+
|
|
216
|
+
// Create dialog
|
|
217
|
+
const dialog = document.createElement('div');
|
|
218
|
+
dialog.className = 'fork-dialog';
|
|
219
|
+
dialog.setAttribute('role', 'dialog');
|
|
220
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
221
|
+
dialog.setAttribute('aria-labelledby', 'fork-dialog-title');
|
|
222
|
+
dialog.onclick = (e) => e.stopPropagation();
|
|
223
|
+
|
|
224
|
+
dialog.innerHTML = `
|
|
225
|
+
<h3 id="fork-dialog-title">🛰️ Fork to New Satellite</h3>
|
|
226
|
+
<div class="fork-dialog-preview" aria-label="Message preview">${escapeHtml(truncated)}</div>
|
|
227
|
+
<label for="fork-dialog-input" class="sr-only">Satellite name</label>
|
|
228
|
+
<input id="fork-dialog-input" type="text" placeholder="Satellite name..." maxlength="${SATELLITE_NAME_MAX_LENGTH}" autofocus>
|
|
229
|
+
<div class="fork-dialog-actions">
|
|
230
|
+
<button type="button" class="fork-dialog-cancel">Cancel</button>
|
|
231
|
+
<button type="button" class="fork-dialog-confirm">Create</button>
|
|
232
|
+
</div>
|
|
233
|
+
`;
|
|
234
|
+
|
|
235
|
+
const input = dialog.querySelector('input');
|
|
236
|
+
const cancelBtn = dialog.querySelector('.fork-dialog-cancel');
|
|
237
|
+
const confirmBtn = dialog.querySelector('.fork-dialog-confirm');
|
|
238
|
+
|
|
239
|
+
// Get all focusable elements in the dialog
|
|
240
|
+
const getFocusableElements = () => {
|
|
241
|
+
return dialog.querySelectorAll('input, button');
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
cancelBtn.onclick = closeDialog;
|
|
245
|
+
confirmBtn.onclick = () => {
|
|
246
|
+
const name = input.value.trim() || 'Forked conversation';
|
|
247
|
+
createForkedSatellite(msgElement, name);
|
|
248
|
+
closeDialog();
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Handle keyboard events for focus trap and shortcuts
|
|
252
|
+
const handleKeydown = (e) => {
|
|
253
|
+
if (e.key === 'Escape') {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
closeDialog();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (e.key === 'Enter' && e.target === input) {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
confirmBtn.click();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Focus trap
|
|
266
|
+
if (e.key === 'Tab') {
|
|
267
|
+
const focusable = getFocusableElements();
|
|
268
|
+
const firstElement = focusable[0];
|
|
269
|
+
const lastElement = focusable[focusable.length - 1];
|
|
270
|
+
|
|
271
|
+
if (e.shiftKey) {
|
|
272
|
+
// Shift+Tab: if on first element, go to last
|
|
273
|
+
if (document.activeElement === firstElement) {
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
lastElement.focus();
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
// Tab: if on last element, go to first
|
|
279
|
+
if (document.activeElement === lastElement) {
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
firstElement.focus();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
dialog.addEventListener('keydown', handleKeydown);
|
|
288
|
+
|
|
289
|
+
backdrop.appendChild(dialog);
|
|
290
|
+
document.body.appendChild(backdrop);
|
|
291
|
+
input.focus();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function createForkedSatellite(msgElement, name) {
|
|
295
|
+
if (!window.UplinkSatellites) {
|
|
296
|
+
if (window.UplinkLogger?.error) {
|
|
297
|
+
window.UplinkLogger.error('Satellites module not available');
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Get all messages up to and including the selected one
|
|
303
|
+
const messages = document.querySelectorAll('.message');
|
|
304
|
+
const messageHistory = [];
|
|
305
|
+
|
|
306
|
+
for (const msg of messages) {
|
|
307
|
+
const text = msg.dataset.originalText || msg.querySelector('.message-text')?.textContent || '';
|
|
308
|
+
const type = msg.classList.contains('user') ? 'user' :
|
|
309
|
+
msg.classList.contains('assistant') ? 'assistant' : 'system';
|
|
310
|
+
|
|
311
|
+
if (type !== 'system' && text) {
|
|
312
|
+
messageHistory.push({ text, type });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Stop after the selected message
|
|
316
|
+
if (msg === msgElement) break;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Create new satellite with this history
|
|
320
|
+
window.UplinkSatellites.launchSatellite(name, messageHistory);
|
|
321
|
+
|
|
322
|
+
// Show notification
|
|
323
|
+
if (window.showNotification) {
|
|
324
|
+
window.showNotification(`Forked to "${name}" with ${messageHistory.length} messages`, 'success');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================
|
|
329
|
+
// UTILITIES
|
|
330
|
+
// ============================================
|
|
331
|
+
function escapeHtml(text) {
|
|
332
|
+
const div = document.createElement('div');
|
|
333
|
+
div.textContent = text;
|
|
334
|
+
return div.innerHTML;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ============================================
|
|
338
|
+
// INTEGRATION
|
|
339
|
+
// ============================================
|
|
340
|
+
|
|
341
|
+
// Hook into message creation
|
|
342
|
+
function init() {
|
|
343
|
+
// Use the chat module's hook system instead of monkey-patching window.addMessage (H-26)
|
|
344
|
+
if (window.UplinkChat?.onMessage) {
|
|
345
|
+
window.UplinkChat.onMessage(({ text, type }) => {
|
|
346
|
+
if (type !== 'system' && text) {
|
|
347
|
+
const messages = document.querySelectorAll('.message');
|
|
348
|
+
const lastMsg = messages[messages.length - 1];
|
|
349
|
+
if (lastMsg && !lastMsg.querySelector('.message-actions')) {
|
|
350
|
+
createActions(lastMsg, type, text);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
} else if (window.addMessage) {
|
|
355
|
+
// Fallback: legacy monkey-patch if hook system not available
|
|
356
|
+
const originalAddMessage = window.addMessage;
|
|
357
|
+
window.addMessage = function(text, type, imageUrl, save) {
|
|
358
|
+
originalAddMessage(text, type, imageUrl, save);
|
|
359
|
+
|
|
360
|
+
// Add actions to the newly added message
|
|
361
|
+
if (type !== 'system') {
|
|
362
|
+
const messages = document.querySelectorAll('.message');
|
|
363
|
+
const lastMsg = messages[messages.length - 1];
|
|
364
|
+
if (lastMsg && !lastMsg.querySelector('.message-actions')) {
|
|
365
|
+
createActions(lastMsg, type, text);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Add actions to existing messages on page load
|
|
372
|
+
setTimeout(() => {
|
|
373
|
+
document.querySelectorAll('.message').forEach(msg => {
|
|
374
|
+
if (msg.querySelector('.message-actions')) return;
|
|
375
|
+
|
|
376
|
+
const type = msg.classList.contains('user') ? 'user' :
|
|
377
|
+
msg.classList.contains('assistant') ? 'assistant' : 'system';
|
|
378
|
+
const text = msg.dataset.originalText || msg.querySelector('.message-text')?.textContent || '';
|
|
379
|
+
|
|
380
|
+
if (type !== 'system' && text) {
|
|
381
|
+
createActions(msg, type, text);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}, 500);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Register with core for coordinated initialization
|
|
388
|
+
UplinkCore.registerModule('message-actions', init);
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Refresh actions on all messages (call after satellite switch or reload)
|
|
392
|
+
*/
|
|
393
|
+
function refreshActions() {
|
|
394
|
+
document.querySelectorAll('.message').forEach(msg => {
|
|
395
|
+
if (msg.querySelector('.message-actions')) return;
|
|
396
|
+
|
|
397
|
+
const type = msg.classList.contains('user') ? 'user' :
|
|
398
|
+
msg.classList.contains('assistant') ? 'assistant' : 'system';
|
|
399
|
+
const text = msg.dataset.originalText || msg.querySelector('.message-text')?.textContent || '';
|
|
400
|
+
|
|
401
|
+
if (type !== 'system' && text) {
|
|
402
|
+
createActions(msg, type, text);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Listen for satellite switch events - clear reply context and refresh actions
|
|
408
|
+
window.addEventListener('uplink:satellite-switching', () => {
|
|
409
|
+
// Clear reply context BEFORE the switch happens
|
|
410
|
+
clearReply();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
window.addEventListener('uplink:satellite-switched', () => {
|
|
414
|
+
// Clear reply again (in case) and refresh actions on new messages
|
|
415
|
+
clearReply();
|
|
416
|
+
refreshActions();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Expose API
|
|
420
|
+
export const UplinkMessageActions = {
|
|
421
|
+
createActions,
|
|
422
|
+
startReply,
|
|
423
|
+
clearReply,
|
|
424
|
+
getReplyContext,
|
|
425
|
+
formatMessageWithReply,
|
|
426
|
+
refreshActions
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// Backward compat: assign to window
|
|
430
|
+
window.UplinkMessageActions = UplinkMessageActions;
|
|
431
|
+
|