claude-simple 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/edge-ctl.js +260 -0
- package/client.js +7 -1
- package/dist/app.0.2.5.min.js +953 -0
- package/dist/index.html +2 -2
- package/dist/{style.0.2.3.min.css → style.0.2.5.min.css} +229 -32
- package/package.json +6 -2
- package/public/app.js +12 -2
- package/public/chat.js +271 -61
- package/public/index.html +33 -3
- package/public/mobile.js +72 -0
- package/public/pwa.js +32 -2
- package/public/shell/boot.js +2 -0
- package/public/shell/hub.js +5 -1
- package/public/shell/layout.js +11 -2
- package/public/shell/templates.js +37 -19
- package/public/shell.js +34 -4
- package/public/state.js +8 -1
- package/public/style.css +228 -31
- package/public/sw.js +2 -2
- package/src/app.js +21 -7
- package/dist/app.0.2.3.min.js +0 -924
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-simple",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-simple": "server.js",
|
|
7
|
-
"claude-simple-client": "client.js"
|
|
7
|
+
"claude-simple-client": "client.js",
|
|
8
|
+
"claude-simple-edge": "bin/edge-ctl.js",
|
|
9
|
+
"claude-edge": "bin/edge-ctl.js",
|
|
10
|
+
"edge-daemon": "bin/edge-ctl.js"
|
|
8
11
|
},
|
|
9
12
|
"files": [
|
|
10
13
|
"server.js",
|
|
11
14
|
"client.js",
|
|
12
15
|
"gateway.js",
|
|
16
|
+
"bin",
|
|
13
17
|
"src",
|
|
14
18
|
"dist",
|
|
15
19
|
"public"
|
package/public/app.js
CHANGED
|
@@ -11,6 +11,7 @@ import { initImagePaste } from './chat.js';
|
|
|
11
11
|
import { initMetaAgent } from './agent-panel.js';
|
|
12
12
|
import { initRouter, applyInitialRoute, parseHashPath } from './router.js';
|
|
13
13
|
import { initPwa } from './pwa.js';
|
|
14
|
+
import { initMobileChrome } from './mobile.js';
|
|
14
15
|
|
|
15
16
|
function markBootReady() {
|
|
16
17
|
const root = document.documentElement;
|
|
@@ -28,6 +29,7 @@ const restoreSession = shouldRestoreSessionOnBoot() || !!(ctx.token && deepLink?
|
|
|
28
29
|
|
|
29
30
|
// 1. Build DOM (welcome already suppressed via html.boot-restore CSS when restoring)
|
|
30
31
|
initShell({ restoreSession });
|
|
32
|
+
initMobileChrome();
|
|
31
33
|
|
|
32
34
|
// 2. Feature panels
|
|
33
35
|
initChat();
|
|
@@ -85,8 +87,16 @@ initRouter(resumeSession, switchTab);
|
|
|
85
87
|
}
|
|
86
88
|
} else {
|
|
87
89
|
clearBootRestore();
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
try {
|
|
91
|
+
await initAuth();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// A failed status probe must never leave the installed app with its
|
|
94
|
+
// whole UI hidden behind boot-pending. initAuth normally handles
|
|
95
|
+
// connection failures itself; this is the last-resort guard.
|
|
96
|
+
console.error('[boot auth]', e);
|
|
97
|
+
} finally {
|
|
98
|
+
markBootReady();
|
|
99
|
+
}
|
|
90
100
|
}
|
|
91
101
|
})();
|
|
92
102
|
|
package/public/chat.js
CHANGED
|
@@ -3,7 +3,7 @@ import { watch, delegate, esc, $ } from './lib.js';
|
|
|
3
3
|
import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEffort, currentPermission,
|
|
4
4
|
currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
|
|
5
5
|
addCustomModel, chatDensity, wsStatus } from './state.js';
|
|
6
|
-
import { sendWs, api, flushWsQueue, clearWsQueue } from './api.js';
|
|
6
|
+
import { sendWs, api, authHeaders, flushWsQueue, clearWsQueue } from './api.js';
|
|
7
7
|
import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
|
|
8
8
|
|
|
9
9
|
function assistantLabel() {
|
|
@@ -706,6 +706,7 @@ export function sendMessage() {
|
|
|
706
706
|
input.value = '';
|
|
707
707
|
autoResize(input);
|
|
708
708
|
updateInputMode('');
|
|
709
|
+
clearPendingAttachments();
|
|
709
710
|
appendSystemMsg(`已排队下一条,当前回复结束后发送:${text.length > 80 ? text.slice(0, 80) + '…' : text}`);
|
|
710
711
|
return;
|
|
711
712
|
}
|
|
@@ -713,6 +714,7 @@ export function sendMessage() {
|
|
|
713
714
|
input.value = '';
|
|
714
715
|
autoResize(input);
|
|
715
716
|
updateInputMode('');
|
|
717
|
+
clearPendingAttachments();
|
|
716
718
|
|
|
717
719
|
if (text.startsWith('/')) {
|
|
718
720
|
if (handleSlashCommand(text)) {
|
|
@@ -723,7 +725,7 @@ export function sendMessage() {
|
|
|
723
725
|
isProcessing.value = true;
|
|
724
726
|
syncWakeLock();
|
|
725
727
|
const now = Date.now();
|
|
726
|
-
if (text
|
|
728
|
+
if (isShellInput(text)) {
|
|
727
729
|
appendMsg('shell', '$ Shell', text.slice(1).trim(), { ts: now });
|
|
728
730
|
// Typed control plane (legacy shell-command still accepted server-side)
|
|
729
731
|
const ok = sendWs({
|
|
@@ -776,7 +778,7 @@ export function newSession() {
|
|
|
776
778
|
|
|
777
779
|
// ── Input helpers ─────────────────────────────────────────────────────────────
|
|
778
780
|
function updateInputMode(value) {
|
|
779
|
-
const isShell = value
|
|
781
|
+
const isShell = isShellInput(value);
|
|
780
782
|
const input = $('chat-input'), btn = $('send-btn');
|
|
781
783
|
if (!input || !btn) return;
|
|
782
784
|
input.classList.toggle('textarea-warning', isShell);
|
|
@@ -785,15 +787,26 @@ function updateInputMode(value) {
|
|
|
785
787
|
btn.classList.toggle('btn-primary', !isShell);
|
|
786
788
|
btn.textContent = isShell ? 'Run' : 'Send';
|
|
787
789
|
const label = AGENT_LABELS[currentAgent.peek()] || 'agent';
|
|
788
|
-
|
|
790
|
+
const compact = typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches;
|
|
791
|
+
if (isShell) {
|
|
792
|
+
input.placeholder = compact ? 'Shell 命令…' : 'Shell command (without !)…';
|
|
793
|
+
} else {
|
|
794
|
+
input.placeholder = compact ? `问 ${label}…` : `Ask ${label}… or !cmd for shell`;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** `` is an attachment reference, not a shell command. */
|
|
799
|
+
function isShellInput(value) {
|
|
800
|
+
const text = String(value || '').trimStart();
|
|
801
|
+
return text.startsWith('!') && !/^!\[[^\]]*\]\([^)]+\)/.test(text);
|
|
789
802
|
}
|
|
790
803
|
|
|
791
804
|
function chatInputMaxHeight() {
|
|
792
805
|
// Keep in sync with #chat-input max-height in style.css (mobile vs desktop).
|
|
793
806
|
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches) {
|
|
794
|
-
return
|
|
807
|
+
return 72;
|
|
795
808
|
}
|
|
796
|
-
return
|
|
809
|
+
return 72;
|
|
797
810
|
}
|
|
798
811
|
|
|
799
812
|
function autoResize(el) {
|
|
@@ -899,74 +912,237 @@ export function appendMsg(role, _label, text, opts = {}) {
|
|
|
899
912
|
|
|
900
913
|
export function appendSystemMsg(text) { appendMsg('system', '', text); }
|
|
901
914
|
|
|
902
|
-
|
|
915
|
+
/** Pending chat attachments shown above the input (paths also inserted into text). */
|
|
916
|
+
const pendingAttachments = [];
|
|
917
|
+
let attachSeq = 0;
|
|
918
|
+
let chatDropBound = false;
|
|
919
|
+
|
|
920
|
+
async function uploadChatBlob(blob, filename) {
|
|
921
|
+
const name = filename || `upload-${Date.now()}`;
|
|
922
|
+
const uploadBlob = await normalizeImageBlob(blob, name);
|
|
923
|
+
const headers = authHeaders({ 'x-filename': name });
|
|
924
|
+
delete headers['Content-Type'];
|
|
925
|
+
// Prefer file MIME so hub base64-encodes binary uploads correctly.
|
|
926
|
+
if (uploadBlob?.type && !headers['Content-Type'] && !headers['content-type']) {
|
|
927
|
+
headers['Content-Type'] = uploadBlob.type;
|
|
928
|
+
}
|
|
903
929
|
const res = await fetch('/api/upload-image', {
|
|
904
930
|
method: 'POST',
|
|
905
|
-
headers
|
|
906
|
-
body:
|
|
931
|
+
headers,
|
|
932
|
+
body: uploadBlob,
|
|
907
933
|
});
|
|
908
|
-
if (!res.ok)
|
|
909
|
-
|
|
934
|
+
if (!res.ok) {
|
|
935
|
+
const err = await res.json().catch(() => ({}));
|
|
936
|
+
throw new Error(err.error || `Upload failed: ${res.status}`);
|
|
937
|
+
}
|
|
938
|
+
const data = await res.json();
|
|
939
|
+
if (!data?.path) throw new Error('upload returned no path');
|
|
940
|
+
return data.path;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function imageMimeFromBytes(bytes, fallback) {
|
|
944
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';
|
|
945
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
|
|
946
|
+
if (bytes.length >= 6 && String.fromCharCode(...bytes.slice(0, 6)) === 'GIF87a') return 'image/gif';
|
|
947
|
+
if (bytes.length >= 12 && String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP') return 'image/webp';
|
|
948
|
+
return fallback || 'image/png';
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Some iOS share sheets provide a .png whose bytes are Base64 text. Decode it once
|
|
952
|
+
* before both upload and preview, while leaving ordinary image files untouched. */
|
|
953
|
+
async function normalizeImageBlob(blob, filename) {
|
|
954
|
+
if (!blob || !isImageFile({ type: blob.type, name: filename })) return blob;
|
|
955
|
+
let head;
|
|
956
|
+
try { head = (await blob.slice(0, 80).text()).trim(); } catch { return blob; }
|
|
957
|
+
// Avoid reading an entire multi-megabyte ordinary photo into a JS string.
|
|
958
|
+
if (!/^(?:data:image\/[a-z0-9.+-]+;base64,|iVBORw0KGgo|\/9j\/|R0lGOD|UklGR)/i.test(head)) return blob;
|
|
959
|
+
let text;
|
|
960
|
+
try { text = (await blob.text()).trim(); } catch { return blob; }
|
|
961
|
+
const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=\s]+)$/i.exec(text);
|
|
962
|
+
const payload = dataUrl?.[1] || text;
|
|
963
|
+
if (!payload || !/^[a-z0-9+/=\s]+$/i.test(payload) || payload.length % 4 === 1) return blob;
|
|
964
|
+
try {
|
|
965
|
+
const binary = atob(payload.replace(/\s/g, ''));
|
|
966
|
+
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
967
|
+
const mime = imageMimeFromBytes(bytes, blob.type);
|
|
968
|
+
// A valid Base64 string alone is not enough: only replace it when it decodes
|
|
969
|
+
// to an actual image signature.
|
|
970
|
+
if (mime === (blob.type || 'image/png') && !/^(image\/png|image\/jpeg|image\/gif|image\/webp)$/.test(mime)) return blob;
|
|
971
|
+
const isImage = bytes.length >= 3 && (
|
|
972
|
+
(bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e)
|
|
973
|
+
|| (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
974
|
+
|| String.fromCharCode(...bytes.slice(0, 6)) === 'GIF87a'
|
|
975
|
+
|| String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP'
|
|
976
|
+
);
|
|
977
|
+
return isImage ? new Blob([bytes], { type: mime }) : blob;
|
|
978
|
+
} catch {
|
|
979
|
+
return blob;
|
|
980
|
+
}
|
|
910
981
|
}
|
|
911
982
|
|
|
912
983
|
function insertAtCursor(ta, text) {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
984
|
+
if (!ta) return;
|
|
985
|
+
const s = ta.selectionStart ?? ta.value.length;
|
|
986
|
+
const e = ta.selectionEnd ?? ta.value.length;
|
|
987
|
+
const padBefore = s > 0 && ta.value[s - 1] !== '\n' ? '\n' : '';
|
|
988
|
+
const insert = padBefore + text + '\n';
|
|
989
|
+
ta.value = ta.value.slice(0, s) + insert + ta.value.slice(e);
|
|
990
|
+
ta.selectionStart = ta.selectionEnd = s + insert.length;
|
|
916
991
|
ta.dispatchEvent(new Event('input'));
|
|
917
992
|
}
|
|
918
993
|
|
|
994
|
+
function isImageFile(file) {
|
|
995
|
+
if (!file) return false;
|
|
996
|
+
if (file.type && file.type.startsWith('image/')) return true;
|
|
997
|
+
return /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i.test(file.name || '');
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function makeAttachRef(file, path) {
|
|
1001
|
+
const name = file?.name || path.split('/').pop() || 'file';
|
|
1002
|
+
if (isImageFile(file)) {
|
|
1003
|
+
attachSeq += 1;
|
|
1004
|
+
const label = `img${attachSeq}`;
|
|
1005
|
+
return { ref: ``, label, kind: 'image' };
|
|
1006
|
+
}
|
|
1007
|
+
attachSeq += 1;
|
|
1008
|
+
const label = `file${attachSeq}`;
|
|
1009
|
+
return { ref: `[${label}](${path})`, label, kind: 'file' };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function clearPendingAttachments() {
|
|
1013
|
+
for (const item of pendingAttachments) {
|
|
1014
|
+
if (item.previewUrl) {
|
|
1015
|
+
try { URL.revokeObjectURL(item.previewUrl); } catch { /* ignore */ }
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
pendingAttachments.length = 0;
|
|
1019
|
+
renderAttachBar();
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function renderAttachBar() {
|
|
1023
|
+
const bar = $('chat-attach-bar');
|
|
1024
|
+
if (!bar) return;
|
|
1025
|
+
if (!pendingAttachments.length) {
|
|
1026
|
+
bar.classList.add('hidden');
|
|
1027
|
+
bar.innerHTML = '';
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
bar.classList.remove('hidden');
|
|
1031
|
+
bar.innerHTML = pendingAttachments.map((item, i) => `
|
|
1032
|
+
<div class="chat-thumb relative" title="${esc(item.ref || item.path)}">
|
|
1033
|
+
${item.previewUrl
|
|
1034
|
+
? `<img src="${item.previewUrl}" alt="${esc(item.label || 'img')}">`
|
|
1035
|
+
: `<span class="chat-thumb-path">${esc(item.label || item.name || 'file')}</span>`}
|
|
1036
|
+
<span class="chat-thumb-label">${esc(item.label || item.kind || '')}</span>
|
|
1037
|
+
<button type="button" class="chat-thumb-x" data-rm-attach="${i}" aria-label="remove">✕</button>
|
|
1038
|
+
</div>`).join('');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function addChatAttachment(file) {
|
|
1042
|
+
if (!file) return;
|
|
1043
|
+
const name = file.name || `upload-${Date.now()}`;
|
|
1044
|
+
const normalized = await normalizeImageBlob(file, name);
|
|
1045
|
+
const path = await uploadChatBlob(normalized, name);
|
|
1046
|
+
const { ref, label, kind } = makeAttachRef(file, path);
|
|
1047
|
+
let previewUrl = '';
|
|
1048
|
+
if (kind === 'image') {
|
|
1049
|
+
try { previewUrl = URL.createObjectURL(normalized); } catch { /* ignore */ }
|
|
1050
|
+
}
|
|
1051
|
+
pendingAttachments.push({ path, ref, label, kind, name, previewUrl });
|
|
1052
|
+
renderAttachBar();
|
|
1053
|
+
insertAtCursor($('chat-input'), ref);
|
|
1054
|
+
return path;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async function uploadFilesList(fileList) {
|
|
1058
|
+
const files = [...(fileList || [])].filter(Boolean);
|
|
1059
|
+
if (!files.length) return;
|
|
1060
|
+
const ta = $('chat-input');
|
|
1061
|
+
if (ta) ta.disabled = true;
|
|
1062
|
+
try {
|
|
1063
|
+
for (const file of files) {
|
|
1064
|
+
try {
|
|
1065
|
+
await addChatAttachment(file);
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
appendSystemMsg(`上传失败 ${file.name || 'file'}: ${err.message}`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
} finally {
|
|
1071
|
+
if (ta) {
|
|
1072
|
+
ta.disabled = false;
|
|
1073
|
+
ta.focus();
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Open file picker (images + any files). */
|
|
1079
|
+
export function attachImage() {
|
|
1080
|
+
const input = $('chat-file-input');
|
|
1081
|
+
if (input) {
|
|
1082
|
+
input.value = '';
|
|
1083
|
+
input.click();
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const fallback = document.createElement('input');
|
|
1087
|
+
fallback.type = 'file';
|
|
1088
|
+
fallback.accept = 'image/*,*/*';
|
|
1089
|
+
fallback.multiple = true;
|
|
1090
|
+
fallback.addEventListener('change', () => uploadFilesList(fallback.files));
|
|
1091
|
+
fallback.click();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
919
1094
|
export function initImagePaste() {
|
|
920
1095
|
document.addEventListener('paste', async e => {
|
|
921
1096
|
const ta = $('chat-input');
|
|
922
|
-
if (!ta
|
|
1097
|
+
if (!ta) return;
|
|
1098
|
+
// Accept paste when focusing input, or when composer is active
|
|
1099
|
+
const composer = $('chat-composer');
|
|
1100
|
+
const inComposer = composer && (composer.contains(document.activeElement) || document.activeElement === ta);
|
|
1101
|
+
if (!inComposer && document.activeElement !== ta) return;
|
|
1102
|
+
|
|
923
1103
|
const items = [...(e.clipboardData?.items || [])];
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
ta.disabled = true;
|
|
931
|
-
try {
|
|
932
|
-
const path = await uploadImageBlob(blob, name);
|
|
933
|
-
insertAtCursor(ta, ``);
|
|
934
|
-
} catch (err) {
|
|
935
|
-
appendSystemMsg(`Image paste failed: ${err.message}`);
|
|
936
|
-
} finally {
|
|
937
|
-
ta.disabled = false;
|
|
938
|
-
ta.focus();
|
|
1104
|
+
const files = [];
|
|
1105
|
+
for (const it of items) {
|
|
1106
|
+
if (it.kind === 'file') {
|
|
1107
|
+
const f = it.getAsFile();
|
|
1108
|
+
if (f) files.push(f);
|
|
1109
|
+
}
|
|
939
1110
|
}
|
|
1111
|
+
// Prefer image paste; also allow non-image files from clipboard when present
|
|
1112
|
+
const imageFiles = files.filter(f => isImageFile(f));
|
|
1113
|
+
const pick = imageFiles.length ? imageFiles : files;
|
|
1114
|
+
if (!pick.length) return;
|
|
1115
|
+
e.preventDefault();
|
|
1116
|
+
await uploadFilesList(pick);
|
|
940
1117
|
});
|
|
941
|
-
}
|
|
942
1118
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
// Insert path reference into chat input
|
|
959
|
-
const ta = $('chat-input');
|
|
960
|
-
if (ta) {
|
|
961
|
-
ta.value += (ta.value ? '\n' : '') + path;
|
|
962
|
-
ta.dispatchEvent(new Event('input'));
|
|
963
|
-
ta.focus();
|
|
964
|
-
}
|
|
965
|
-
appendSystemMsg(`📎 ${path}`);
|
|
966
|
-
} catch (e) { appendSystemMsg(`Upload failed: ${e.message}`); }
|
|
1119
|
+
if (chatDropBound) return;
|
|
1120
|
+
chatDropBound = true;
|
|
1121
|
+
document.addEventListener('dragover', e => {
|
|
1122
|
+
const composer = $('chat-composer');
|
|
1123
|
+
if (!composer || !e.dataTransfer?.types?.includes('Files')) return;
|
|
1124
|
+
if (!composer.contains(e.target) && e.target !== composer) return;
|
|
1125
|
+
e.preventDefault();
|
|
1126
|
+
composer.classList.add('chat-drop-active');
|
|
1127
|
+
});
|
|
1128
|
+
document.addEventListener('dragleave', e => {
|
|
1129
|
+
const composer = $('chat-composer');
|
|
1130
|
+
if (!composer) return;
|
|
1131
|
+
if (e.target === composer || composer.contains(e.target)) {
|
|
1132
|
+
// leaving to outside
|
|
1133
|
+
if (!composer.contains(e.relatedTarget)) composer.classList.remove('chat-drop-active');
|
|
967
1134
|
}
|
|
968
1135
|
});
|
|
969
|
-
|
|
1136
|
+
document.addEventListener('drop', async e => {
|
|
1137
|
+
const composer = $('chat-composer');
|
|
1138
|
+
if (!composer) return;
|
|
1139
|
+
composer.classList.remove('chat-drop-active');
|
|
1140
|
+
if (!composer.contains(e.target) && e.target !== composer) return;
|
|
1141
|
+
const files = [...(e.dataTransfer?.files || [])];
|
|
1142
|
+
if (!files.length) return;
|
|
1143
|
+
e.preventDefault();
|
|
1144
|
+
await uploadFilesList(files);
|
|
1145
|
+
});
|
|
970
1146
|
}
|
|
971
1147
|
|
|
972
1148
|
function fmtNum(n) { return n >= 1000 ? `${(n/1000).toFixed(1)}k` : String(n); }
|
|
@@ -975,8 +1151,8 @@ function appendTokenBar(text) {
|
|
|
975
1151
|
const msgs = $('messages');
|
|
976
1152
|
if (!msgs) return;
|
|
977
1153
|
msgs.insertAdjacentHTML('beforeend',
|
|
978
|
-
`<div class="
|
|
979
|
-
<span class="
|
|
1154
|
+
`<div class="token-bar select-none">
|
|
1155
|
+
<span class="token-bar-mark">◈</span><span class="token-bar-text">${esc(text)}</span>
|
|
980
1156
|
</div>`);
|
|
981
1157
|
stickBottom(msgs);
|
|
982
1158
|
}
|
|
@@ -1308,13 +1484,47 @@ export function initChat() {
|
|
|
1308
1484
|
|
|
1309
1485
|
watch(chatDensity, () => applyChatDensity());
|
|
1310
1486
|
|
|
1311
|
-
// Chat input: Enter = send
|
|
1487
|
+
// Chat input: ⌘/Ctrl+Enter = send. On phones, Enter sends (Shift+Enter newline).
|
|
1312
1488
|
delegate.on('keydown', '#chat-input', e => {
|
|
1313
|
-
|
|
1314
|
-
|
|
1489
|
+
if (e.key !== 'Enter') return;
|
|
1490
|
+
const combo = e.metaKey || e.ctrlKey;
|
|
1491
|
+
const phone = window.matchMedia('(max-width: 640px)').matches && !e.shiftKey && !combo;
|
|
1492
|
+
if (combo || phone) { e.preventDefault(); sendMessage(); }
|
|
1493
|
+
});
|
|
1494
|
+
delegate.on('submit', '#chat-form', e => {
|
|
1495
|
+
e.preventDefault();
|
|
1496
|
+
sendMessage();
|
|
1315
1497
|
});
|
|
1316
1498
|
delegate.on('input', '#chat-input', (e, el) => { autoResize(el); updateInputMode(el.value); });
|
|
1317
1499
|
|
|
1500
|
+
// Attach images / files
|
|
1501
|
+
delegate.on('click', '#btn-attach', e => {
|
|
1502
|
+
e.preventDefault();
|
|
1503
|
+
attachImage();
|
|
1504
|
+
});
|
|
1505
|
+
delegate.on('change', '#chat-file-input', (e, el) => {
|
|
1506
|
+
const files = el?.files;
|
|
1507
|
+
if (files?.length) uploadFilesList(files);
|
|
1508
|
+
if (el) el.value = '';
|
|
1509
|
+
});
|
|
1510
|
+
delegate.on('click', '[data-rm-attach]', (e, el) => {
|
|
1511
|
+
e.preventDefault();
|
|
1512
|
+
e.stopPropagation();
|
|
1513
|
+
const i = parseInt(el.dataset.rmAttach, 10);
|
|
1514
|
+
if (Number.isNaN(i) || i < 0 || i >= pendingAttachments.length) return;
|
|
1515
|
+
const item = pendingAttachments[i];
|
|
1516
|
+
if (item?.previewUrl) {
|
|
1517
|
+
try { URL.revokeObjectURL(item.previewUrl); } catch { /* ignore */ }
|
|
1518
|
+
}
|
|
1519
|
+
const ta = $('chat-input');
|
|
1520
|
+
if (ta && item?.ref) {
|
|
1521
|
+
ta.value = ta.value.split(item.ref).join('').replace(/\n{3,}/g, '\n\n');
|
|
1522
|
+
ta.dispatchEvent(new Event('input'));
|
|
1523
|
+
}
|
|
1524
|
+
pendingAttachments.splice(i, 1);
|
|
1525
|
+
renderAttachBar();
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1318
1528
|
// Permission buttons → approval.respond (typed control plane)
|
|
1319
1529
|
delegate.on('click', '[data-perm-id]', (e, el) => {
|
|
1320
1530
|
sendWs({
|
package/public/index.html
CHANGED
|
@@ -27,6 +27,24 @@
|
|
|
27
27
|
html.boot-ready #root {
|
|
28
28
|
visibility: visible !important;
|
|
29
29
|
}
|
|
30
|
+
/* If a cached/incomplete module cannot start, do not leave a standalone
|
|
31
|
+
iOS/Android window as a featureless dark screen. */
|
|
32
|
+
#boot-fallback {
|
|
33
|
+
position: fixed;
|
|
34
|
+
inset: 0;
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
padding: 1.5rem;
|
|
39
|
+
background: #0f172a;
|
|
40
|
+
color: #e2e8f0;
|
|
41
|
+
font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
42
|
+
text-align: center;
|
|
43
|
+
}
|
|
44
|
+
#boot-fallback-error { display: none; }
|
|
45
|
+
html.boot-ready #boot-fallback { display: none; }
|
|
46
|
+
html.boot-timeout #boot-fallback-loading { display: none; }
|
|
47
|
+
html.boot-timeout #boot-fallback-error { display: block; }
|
|
30
48
|
/* Session deep-link: never show welcome calendar during boot */
|
|
31
49
|
html.boot-restore #welcome {
|
|
32
50
|
display: none !important;
|
|
@@ -45,7 +63,7 @@
|
|
|
45
63
|
display: none !important;
|
|
46
64
|
}
|
|
47
65
|
</style>
|
|
48
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
|
49
67
|
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
|
50
68
|
<meta name="theme-color" content="#0f172a">
|
|
51
69
|
<meta name="color-scheme" content="dark light">
|
|
@@ -65,10 +83,22 @@
|
|
|
65
83
|
<script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
|
|
66
84
|
<link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css">
|
|
67
85
|
<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
|
|
68
|
-
<link rel="stylesheet" href="style.css?v=ui-
|
|
86
|
+
<link rel="stylesheet" href="style.css?v=ui-10">
|
|
69
87
|
</head>
|
|
70
88
|
<body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
|
|
71
89
|
<div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
|
|
72
|
-
<
|
|
90
|
+
<div id="boot-fallback" role="status" aria-live="polite">
|
|
91
|
+
<div id="boot-fallback-loading">正在打开 Agent UI…</div>
|
|
92
|
+
<div id="boot-fallback-error">应用未能完成加载。请确认网络和服务可用,然后刷新或从主屏幕重新打开。</div>
|
|
93
|
+
</div>
|
|
94
|
+
<script>
|
|
95
|
+
setTimeout(function () {
|
|
96
|
+
if (document.documentElement.classList.contains('boot-pending')
|
|
97
|
+
&& !document.documentElement.classList.contains('boot-ready')) {
|
|
98
|
+
document.documentElement.classList.add('boot-timeout');
|
|
99
|
+
}
|
|
100
|
+
}, 10000);
|
|
101
|
+
</script>
|
|
102
|
+
<script type="module" src="app.js?v=ui-10"></script>
|
|
73
103
|
</body>
|
|
74
104
|
</html>
|
package/public/mobile.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// mobile.js — visual viewport (iOS keyboard) + isolate hidden form fields
|
|
2
|
+
// Safari's accessory bar / "Not Secure" chrome cannot be painted over from the page.
|
|
3
|
+
|
|
4
|
+
function setInert(el, on) {
|
|
5
|
+
if (!el) return;
|
|
6
|
+
if (on) el.setAttribute('inert', '');
|
|
7
|
+
else el.removeAttribute('inert');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isHidden(el) {
|
|
11
|
+
if (!el) return true;
|
|
12
|
+
if (el.classList.contains('hidden')) return true;
|
|
13
|
+
const d = el.style.display;
|
|
14
|
+
return d === 'none';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Keep iOS form-assistant from tabbing through off-screen / hidden inputs. */
|
|
18
|
+
export function syncFocusScope() {
|
|
19
|
+
const mobile = typeof window !== 'undefined'
|
|
20
|
+
&& window.matchMedia('(max-width: 640px)').matches;
|
|
21
|
+
|
|
22
|
+
const sidebar = document.getElementById('sidebar');
|
|
23
|
+
if (sidebar) {
|
|
24
|
+
const open = !mobile || sidebar.classList.contains('open');
|
|
25
|
+
setInert(sidebar, !open);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setInert(document.getElementById('welcome'), isHidden(document.getElementById('welcome')));
|
|
29
|
+
setInert(document.getElementById('auth-screen'), isHidden(document.getElementById('auth-screen')));
|
|
30
|
+
setInert(document.getElementById('chat-opts'), isHidden(document.getElementById('chat-opts')));
|
|
31
|
+
setInert(document.getElementById('machine-picker'), isHidden(document.getElementById('machine-picker')));
|
|
32
|
+
|
|
33
|
+
for (const id of ['files', 'git', 'shell', 'memory']) {
|
|
34
|
+
const el = document.getElementById(`tab-${id}`);
|
|
35
|
+
setInert(el, isHidden(el));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const chat = document.getElementById('tab-chat');
|
|
39
|
+
if (chat) setInert(chat, isHidden(chat));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function applyVisualViewport() {
|
|
43
|
+
const vv = window.visualViewport;
|
|
44
|
+
const root = document.documentElement;
|
|
45
|
+
if (!vv) return;
|
|
46
|
+
|
|
47
|
+
const keyboardish = vv.height < window.innerHeight - 80;
|
|
48
|
+
root.classList.toggle('vv-keyboard', keyboardish);
|
|
49
|
+
root.style.setProperty('--vvh', `${Math.round(vv.height)}px`);
|
|
50
|
+
root.style.setProperty('--vv-top', `${Math.round(vv.offsetTop)}px`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function initMobileChrome() {
|
|
54
|
+
syncFocusScope();
|
|
55
|
+
applyVisualViewport();
|
|
56
|
+
|
|
57
|
+
const vv = window.visualViewport;
|
|
58
|
+
if (vv) {
|
|
59
|
+
vv.addEventListener('resize', applyVisualViewport);
|
|
60
|
+
vv.addEventListener('scroll', applyVisualViewport);
|
|
61
|
+
}
|
|
62
|
+
window.addEventListener('resize', () => {
|
|
63
|
+
applyVisualViewport();
|
|
64
|
+
syncFocusScope();
|
|
65
|
+
});
|
|
66
|
+
window.addEventListener('orientationchange', () => {
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
applyVisualViewport();
|
|
69
|
+
syncFocusScope();
|
|
70
|
+
}, 250);
|
|
71
|
+
});
|
|
72
|
+
}
|
package/public/pwa.js
CHANGED
|
@@ -11,6 +11,24 @@ function isStandalone() {
|
|
|
11
11
|
);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function isIpHost(host) {
|
|
15
|
+
const h = String(host || '').replace(/^\[|\]$/g, '');
|
|
16
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return true;
|
|
17
|
+
// Bare IPv6 (no zone id)
|
|
18
|
+
if (h.includes(':') && /^[0-9a-f:]+$/i.test(h)) return true;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** SW requires a trusted TLS cert. IP + self-signed HTTPS always fails fetch(sw.js). */
|
|
23
|
+
function canRegisterSw() {
|
|
24
|
+
if (!('serviceWorker' in navigator)) return false;
|
|
25
|
+
if (!window.isSecureContext) return false;
|
|
26
|
+
const host = location.hostname;
|
|
27
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
|
|
28
|
+
if (isIpHost(host)) return false;
|
|
29
|
+
return location.protocol === 'https:';
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
function showInstallButton() {
|
|
15
33
|
document.getElementById('btn-pwa-install')?.classList.remove('hidden');
|
|
16
34
|
}
|
|
@@ -40,7 +58,14 @@ export function initPwa() {
|
|
|
40
58
|
requestAnimationFrame(() => showInstallButton());
|
|
41
59
|
}
|
|
42
60
|
|
|
43
|
-
if (!(
|
|
61
|
+
if (!canRegisterSw()) {
|
|
62
|
+
if ('serviceWorker' in navigator) {
|
|
63
|
+
navigator.serviceWorker.getRegistrations()
|
|
64
|
+
.then((regs) => regs.forEach((r) => r.unregister()))
|
|
65
|
+
.catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
44
69
|
|
|
45
70
|
// Register after first paint so install doesn't compete with boot.
|
|
46
71
|
const register = () => {
|
|
@@ -56,7 +81,12 @@ export function initPwa() {
|
|
|
56
81
|
});
|
|
57
82
|
});
|
|
58
83
|
})
|
|
59
|
-
.catch((
|
|
84
|
+
.catch(() => {
|
|
85
|
+
// Typical on self-signed / corporate MITM certs. App works without SW.
|
|
86
|
+
navigator.serviceWorker.getRegistrations()
|
|
87
|
+
.then((regs) => regs.forEach((r) => r.unregister()))
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
});
|
|
60
90
|
};
|
|
61
91
|
|
|
62
92
|
if (document.readyState === 'complete') setTimeout(register, 0);
|