claude-simple 0.2.3 → 0.2.4

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/dist/index.html CHANGED
@@ -4,13 +4,13 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
6
6
  <title>Claude Code</title>
7
- <link rel="stylesheet" href="/style.0.2.3.min.css">
7
+ <link rel="stylesheet" href="/style.0.2.4.min.css">
8
8
  <link rel="stylesheet" href="/highlight.min.css">
9
9
  <script src="/marked.min.js"></script>
10
10
  <script src="/highlight.min.js"></script>
11
11
  </head>
12
12
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
13
13
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
14
- <script type="module" src="/app.0.2.3.min.js"></script>
14
+ <script type="module" src="/app.0.2.4.min.js"></script>
15
15
  </body>
16
16
  </html>
@@ -30,6 +30,7 @@
30
30
  line-height: 1.35 !important;
31
31
  }
32
32
  #btn-opts,
33
+ #btn-attach,
33
34
  #send-btn,
34
35
  #stop-btn {
35
36
  height: 36px !important;
@@ -886,11 +887,71 @@ html.pwa-standalone .ma-panel {
886
887
  /* ── Chat composer ───────────────────────────────────────────────────────── */
887
888
  /* Single outer border only — no DaisyUI nested focus ring / inner outline. */
888
889
  #composer-row #btn-opts,
890
+ #composer-row #btn-attach,
889
891
  #composer-row #send-btn,
890
892
  #composer-row #stop-btn {
891
893
  height: 40px;
892
894
  min-height: 40px;
893
895
  }
896
+ #chat-composer.chat-drop-active {
897
+ outline: 2px dashed color-mix(in oklch, var(--color-primary) 70%, transparent);
898
+ outline-offset: -2px;
899
+ background: color-mix(in oklch, var(--color-primary) 8%, var(--color-base-200));
900
+ }
901
+ #chat-attach-bar:not(.hidden) {
902
+ display: flex;
903
+ }
904
+ .chat-thumb {
905
+ width: 56px;
906
+ height: 56px;
907
+ border-radius: 8px;
908
+ overflow: hidden;
909
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 14%, transparent);
910
+ position: relative;
911
+ flex-shrink: 0;
912
+ background: var(--color-base-100);
913
+ }
914
+ .chat-thumb img { width: 100%; height: 100%; object-fit: cover; }
915
+ .chat-thumb-path {
916
+ display: flex;
917
+ align-items: center;
918
+ justify-content: center;
919
+ width: 100%;
920
+ height: 100%;
921
+ font-size: 10px;
922
+ padding: 4px;
923
+ text-align: center;
924
+ word-break: break-all;
925
+ line-height: 1.15;
926
+ color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
927
+ background: color-mix(in oklch, var(--color-base-content) 6%, var(--color-base-100));
928
+ }
929
+ .chat-thumb-label {
930
+ position: absolute;
931
+ left: 0;
932
+ bottom: 0;
933
+ right: 0;
934
+ font-size: 9px;
935
+ text-align: center;
936
+ background: rgba(0,0,0,.55);
937
+ color: #fff;
938
+ line-height: 1.2;
939
+ padding: 1px 0;
940
+ }
941
+ .chat-thumb-x {
942
+ position: absolute;
943
+ top: 0;
944
+ right: 0;
945
+ background: rgba(0,0,0,.65);
946
+ color: #fff;
947
+ border: 0;
948
+ font-size: 10px;
949
+ width: 16px;
950
+ height: 16px;
951
+ cursor: pointer;
952
+ line-height: 1;
953
+ z-index: 1;
954
+ }
894
955
  #chat-input.chat-input,
895
956
  #chat-input {
896
957
  width: 100%;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
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)) {
@@ -899,74 +901,195 @@ export function appendMsg(role, _label, text, opts = {}) {
899
901
 
900
902
  export function appendSystemMsg(text) { appendMsg('system', '', text); }
901
903
 
902
- async function uploadImageBlob(blob, filename) {
904
+ /** Pending chat attachments shown above the input (paths also inserted into text). */
905
+ const pendingAttachments = [];
906
+ let attachSeq = 0;
907
+ let chatDropBound = false;
908
+
909
+ async function uploadChatBlob(blob, filename) {
910
+ const name = filename || `upload-${Date.now()}`;
911
+ const headers = authHeaders({ 'x-filename': name });
912
+ delete headers['Content-Type'];
913
+ // Prefer file MIME so hub base64-encodes binary uploads correctly.
914
+ if (blob?.type && !headers['Content-Type'] && !headers['content-type']) {
915
+ headers['Content-Type'] = blob.type;
916
+ }
903
917
  const res = await fetch('/api/upload-image', {
904
918
  method: 'POST',
905
- headers: { Authorization: `Bearer ${ctx.token}`, 'x-filename': filename },
919
+ headers,
906
920
  body: blob,
907
921
  });
908
- if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
909
- return (await res.json()).path;
922
+ if (!res.ok) {
923
+ const err = await res.json().catch(() => ({}));
924
+ throw new Error(err.error || `Upload failed: ${res.status}`);
925
+ }
926
+ const data = await res.json();
927
+ if (!data?.path) throw new Error('upload returned no path');
928
+ return data.path;
910
929
  }
911
930
 
912
931
  function insertAtCursor(ta, text) {
913
- const s = ta.selectionStart, e = ta.selectionEnd;
914
- ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
915
- ta.selectionStart = ta.selectionEnd = s + text.length;
932
+ if (!ta) return;
933
+ const s = ta.selectionStart ?? ta.value.length;
934
+ const e = ta.selectionEnd ?? ta.value.length;
935
+ const padBefore = s > 0 && ta.value[s - 1] !== '\n' ? '\n' : '';
936
+ const insert = padBefore + text + '\n';
937
+ ta.value = ta.value.slice(0, s) + insert + ta.value.slice(e);
938
+ ta.selectionStart = ta.selectionEnd = s + insert.length;
916
939
  ta.dispatchEvent(new Event('input'));
917
940
  }
918
941
 
942
+ function isImageFile(file) {
943
+ if (!file) return false;
944
+ if (file.type && file.type.startsWith('image/')) return true;
945
+ return /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i.test(file.name || '');
946
+ }
947
+
948
+ function makeAttachRef(file, path) {
949
+ const name = file?.name || path.split('/').pop() || 'file';
950
+ if (isImageFile(file)) {
951
+ attachSeq += 1;
952
+ const label = `img${attachSeq}`;
953
+ return { ref: `![${label}](${path})`, label, kind: 'image' };
954
+ }
955
+ attachSeq += 1;
956
+ const label = `file${attachSeq}`;
957
+ return { ref: `[${label}](${path})`, label, kind: 'file' };
958
+ }
959
+
960
+ function clearPendingAttachments() {
961
+ for (const item of pendingAttachments) {
962
+ if (item.previewUrl) {
963
+ try { URL.revokeObjectURL(item.previewUrl); } catch { /* ignore */ }
964
+ }
965
+ }
966
+ pendingAttachments.length = 0;
967
+ renderAttachBar();
968
+ }
969
+
970
+ function renderAttachBar() {
971
+ const bar = $('chat-attach-bar');
972
+ if (!bar) return;
973
+ if (!pendingAttachments.length) {
974
+ bar.classList.add('hidden');
975
+ bar.innerHTML = '';
976
+ return;
977
+ }
978
+ bar.classList.remove('hidden');
979
+ bar.innerHTML = pendingAttachments.map((item, i) => `
980
+ <div class="chat-thumb relative" title="${esc(item.ref || item.path)}">
981
+ ${item.previewUrl
982
+ ? `<img src="${item.previewUrl}" alt="${esc(item.label || 'img')}">`
983
+ : `<span class="chat-thumb-path">${esc(item.label || item.name || 'file')}</span>`}
984
+ <span class="chat-thumb-label">${esc(item.label || item.kind || '')}</span>
985
+ <button type="button" class="chat-thumb-x" data-rm-attach="${i}" aria-label="remove">✕</button>
986
+ </div>`).join('');
987
+ }
988
+
989
+ async function addChatAttachment(file) {
990
+ if (!file) return;
991
+ const name = file.name || `upload-${Date.now()}`;
992
+ const path = await uploadChatBlob(file, name);
993
+ const { ref, label, kind } = makeAttachRef(file, path);
994
+ let previewUrl = '';
995
+ if (kind === 'image') {
996
+ try { previewUrl = URL.createObjectURL(file); } catch { /* ignore */ }
997
+ }
998
+ pendingAttachments.push({ path, ref, label, kind, name, previewUrl });
999
+ renderAttachBar();
1000
+ insertAtCursor($('chat-input'), ref);
1001
+ return path;
1002
+ }
1003
+
1004
+ async function uploadFilesList(fileList) {
1005
+ const files = [...(fileList || [])].filter(Boolean);
1006
+ if (!files.length) return;
1007
+ const ta = $('chat-input');
1008
+ if (ta) ta.disabled = true;
1009
+ try {
1010
+ for (const file of files) {
1011
+ try {
1012
+ await addChatAttachment(file);
1013
+ } catch (err) {
1014
+ appendSystemMsg(`上传失败 ${file.name || 'file'}: ${err.message}`);
1015
+ }
1016
+ }
1017
+ } finally {
1018
+ if (ta) {
1019
+ ta.disabled = false;
1020
+ ta.focus();
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ /** Open file picker (images + any files). */
1026
+ export function attachImage() {
1027
+ const input = $('chat-file-input');
1028
+ if (input) {
1029
+ input.value = '';
1030
+ input.click();
1031
+ return;
1032
+ }
1033
+ const fallback = document.createElement('input');
1034
+ fallback.type = 'file';
1035
+ fallback.accept = 'image/*,*/*';
1036
+ fallback.multiple = true;
1037
+ fallback.addEventListener('change', () => uploadFilesList(fallback.files));
1038
+ fallback.click();
1039
+ }
1040
+
919
1041
  export function initImagePaste() {
920
1042
  document.addEventListener('paste', async e => {
921
1043
  const ta = $('chat-input');
922
- if (!ta || document.activeElement !== ta) return;
1044
+ if (!ta) return;
1045
+ // Accept paste when focusing input, or when composer is active
1046
+ const composer = $('chat-composer');
1047
+ const inComposer = composer && (composer.contains(document.activeElement) || document.activeElement === ta);
1048
+ if (!inComposer && document.activeElement !== ta) return;
1049
+
923
1050
  const items = [...(e.clipboardData?.items || [])];
924
- const imageItem = items.find(i => i.type.startsWith('image/'));
925
- if (!imageItem) return;
926
- e.preventDefault();
927
- const blob = imageItem.getAsFile();
928
- const ext = imageItem.type.split('/')[1] || 'png';
929
- const name = `paste-${Date.now()}.${ext}`;
930
- ta.disabled = true;
931
- try {
932
- const path = await uploadImageBlob(blob, name);
933
- insertAtCursor(ta, `![${name}](${path})`);
934
- } catch (err) {
935
- appendSystemMsg(`Image paste failed: ${err.message}`);
936
- } finally {
937
- ta.disabled = false;
938
- ta.focus();
1051
+ const files = [];
1052
+ for (const it of items) {
1053
+ if (it.kind === 'file') {
1054
+ const f = it.getAsFile();
1055
+ if (f) files.push(f);
1056
+ }
939
1057
  }
1058
+ // Prefer image paste; also allow non-image files from clipboard when present
1059
+ const imageFiles = files.filter(f => isImageFile(f));
1060
+ const pick = imageFiles.length ? imageFiles : files;
1061
+ if (!pick.length) return;
1062
+ e.preventDefault();
1063
+ await uploadFilesList(pick);
940
1064
  });
941
- }
942
1065
 
943
- export function attachImage() {
944
- const input = document.createElement('input');
945
- input.type = 'file';
946
- input.accept = 'image/*';
947
- input.multiple = true;
948
- input.addEventListener('change', async () => {
949
- for (const file of input.files) {
950
- appendSystemMsg(`Uploading ${file.name}…`);
951
- try {
952
- const res = await fetch('/api/upload-image', {
953
- method: 'POST',
954
- headers: { Authorization: `Bearer ${ctx.token}`, 'x-filename': file.name },
955
- body: file,
956
- });
957
- const { path } = await res.json();
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}`); }
1066
+ if (chatDropBound) return;
1067
+ chatDropBound = true;
1068
+ document.addEventListener('dragover', e => {
1069
+ const composer = $('chat-composer');
1070
+ if (!composer || !e.dataTransfer?.types?.includes('Files')) return;
1071
+ if (!composer.contains(e.target) && e.target !== composer) return;
1072
+ e.preventDefault();
1073
+ composer.classList.add('chat-drop-active');
1074
+ });
1075
+ document.addEventListener('dragleave', e => {
1076
+ const composer = $('chat-composer');
1077
+ if (!composer) return;
1078
+ if (e.target === composer || composer.contains(e.target)) {
1079
+ // leaving to outside
1080
+ if (!composer.contains(e.relatedTarget)) composer.classList.remove('chat-drop-active');
967
1081
  }
968
1082
  });
969
- input.click();
1083
+ document.addEventListener('drop', async e => {
1084
+ const composer = $('chat-composer');
1085
+ if (!composer) return;
1086
+ composer.classList.remove('chat-drop-active');
1087
+ if (!composer.contains(e.target) && e.target !== composer) return;
1088
+ const files = [...(e.dataTransfer?.files || [])];
1089
+ if (!files.length) return;
1090
+ e.preventDefault();
1091
+ await uploadFilesList(files);
1092
+ });
970
1093
  }
971
1094
 
972
1095
  function fmtNum(n) { return n >= 1000 ? `${(n/1000).toFixed(1)}k` : String(n); }
@@ -1315,6 +1438,34 @@ export function initChat() {
1315
1438
  });
1316
1439
  delegate.on('input', '#chat-input', (e, el) => { autoResize(el); updateInputMode(el.value); });
1317
1440
 
1441
+ // Attach images / files
1442
+ delegate.on('click', '#btn-attach', e => {
1443
+ e.preventDefault();
1444
+ attachImage();
1445
+ });
1446
+ delegate.on('change', '#chat-file-input', (e, el) => {
1447
+ const files = el?.files;
1448
+ if (files?.length) uploadFilesList(files);
1449
+ if (el) el.value = '';
1450
+ });
1451
+ delegate.on('click', '[data-rm-attach]', (e, el) => {
1452
+ e.preventDefault();
1453
+ e.stopPropagation();
1454
+ const i = parseInt(el.dataset.rmAttach, 10);
1455
+ if (Number.isNaN(i) || i < 0 || i >= pendingAttachments.length) return;
1456
+ const item = pendingAttachments[i];
1457
+ if (item?.previewUrl) {
1458
+ try { URL.revokeObjectURL(item.previewUrl); } catch { /* ignore */ }
1459
+ }
1460
+ const ta = $('chat-input');
1461
+ if (ta && item?.ref) {
1462
+ ta.value = ta.value.split(item.ref).join('').replace(/\n{3,}/g, '\n\n');
1463
+ ta.dispatchEvent(new Event('input'));
1464
+ }
1465
+ pendingAttachments.splice(i, 1);
1466
+ renderAttachBar();
1467
+ });
1468
+
1318
1469
  // Permission buttons → approval.respond (typed control plane)
1319
1470
  delegate.on('click', '[data-perm-id]', (e, el) => {
1320
1471
  sendWs({
@@ -239,6 +239,7 @@ export const AppShell = (opts = {}) => {
239
239
  </div>
240
240
  <div id="permission-requests"></div>
241
241
  <div id="chat-composer" class="flex flex-col border-t border-base-300 bg-base-200 flex-shrink-0">
242
+ <div id="chat-attach-bar" class="hidden flex flex-wrap gap-1.5 px-3 pt-2"></div>
242
243
  <div id="chat-opts" class="hidden flex items-center gap-1.5 px-3 pt-2 flex-wrap">
243
244
  <select id="sel-agent" class="select select-xs select-bordered text-xs font-semibold" title="Agent">
244
245
  <option value="claude">Claude</option>
@@ -283,8 +284,10 @@ export const AppShell = (opts = {}) => {
283
284
  </select>
284
285
  </div>
285
286
  <div id="composer-row" class="flex gap-2 items-end px-3 pt-2 pb-2">
286
- <button id="btn-opts" class="btn btn-ghost btn-sm px-2 text-base-content/30 hover:text-base-content flex-shrink-0" title="Options">⚙</button>
287
- <textarea id="chat-input" rows="1" placeholder="Ask… (!cmd shell · ⌘↵ send)"
287
+ <button type="button" id="btn-opts" class="btn btn-ghost btn-sm px-2 text-base-content/30 hover:text-base-content flex-shrink-0" title="Options">⚙</button>
288
+ <button type="button" id="btn-attach" class="btn btn-ghost btn-sm px-2 text-base-content/40 hover:text-base-content flex-shrink-0" title="添加图片 / 文件">📎</button>
289
+ <input type="file" id="chat-file-input" class="hidden" multiple accept="image/*,*/*">
290
+ <textarea id="chat-input" rows="1" placeholder="Ask… (📎 图片/文件 · !cmd · ⌘↵)"
288
291
  class="chat-input flex-1 text-sm resize-none leading-relaxed"
289
292
  name="agent-chat-message"
290
293
  autocomplete="off"
@@ -295,8 +298,8 @@ export const AppShell = (opts = {}) => {
295
298
  data-lpignore="true"
296
299
  data-form-type="other"
297
300
  data-bwignore="true"></textarea>
298
- <button id="send-btn" class="btn btn-primary btn-sm flex-shrink-0">Send</button>
299
- <button id="stop-btn" class="btn btn-error btn-sm hidden flex-shrink-0">■</button>
301
+ <button type="button" id="send-btn" class="btn btn-primary btn-sm flex-shrink-0">Send</button>
302
+ <button type="button" id="stop-btn" class="btn btn-error btn-sm hidden flex-shrink-0">■</button>
300
303
  </div>
301
304
  </div>
302
305
  </div>
package/public/style.css CHANGED
@@ -28,6 +28,7 @@
28
28
  line-height: 1.35 !important;
29
29
  }
30
30
  #btn-opts,
31
+ #btn-attach,
31
32
  #send-btn,
32
33
  #stop-btn {
33
34
  height: 36px !important;
@@ -884,11 +885,71 @@ html.pwa-standalone .ma-panel {
884
885
  /* ── Chat composer ───────────────────────────────────────────────────────── */
885
886
  /* Single outer border only — no DaisyUI nested focus ring / inner outline. */
886
887
  #composer-row #btn-opts,
888
+ #composer-row #btn-attach,
887
889
  #composer-row #send-btn,
888
890
  #composer-row #stop-btn {
889
891
  height: 40px;
890
892
  min-height: 40px;
891
893
  }
894
+ #chat-composer.chat-drop-active {
895
+ outline: 2px dashed color-mix(in oklch, var(--color-primary) 70%, transparent);
896
+ outline-offset: -2px;
897
+ background: color-mix(in oklch, var(--color-primary) 8%, var(--color-base-200));
898
+ }
899
+ #chat-attach-bar:not(.hidden) {
900
+ display: flex;
901
+ }
902
+ .chat-thumb {
903
+ width: 56px;
904
+ height: 56px;
905
+ border-radius: 8px;
906
+ overflow: hidden;
907
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 14%, transparent);
908
+ position: relative;
909
+ flex-shrink: 0;
910
+ background: var(--color-base-100);
911
+ }
912
+ .chat-thumb img { width: 100%; height: 100%; object-fit: cover; }
913
+ .chat-thumb-path {
914
+ display: flex;
915
+ align-items: center;
916
+ justify-content: center;
917
+ width: 100%;
918
+ height: 100%;
919
+ font-size: 10px;
920
+ padding: 4px;
921
+ text-align: center;
922
+ word-break: break-all;
923
+ line-height: 1.15;
924
+ color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
925
+ background: color-mix(in oklch, var(--color-base-content) 6%, var(--color-base-100));
926
+ }
927
+ .chat-thumb-label {
928
+ position: absolute;
929
+ left: 0;
930
+ bottom: 0;
931
+ right: 0;
932
+ font-size: 9px;
933
+ text-align: center;
934
+ background: rgba(0,0,0,.55);
935
+ color: #fff;
936
+ line-height: 1.2;
937
+ padding: 1px 0;
938
+ }
939
+ .chat-thumb-x {
940
+ position: absolute;
941
+ top: 0;
942
+ right: 0;
943
+ background: rgba(0,0,0,.65);
944
+ color: #fff;
945
+ border: 0;
946
+ font-size: 10px;
947
+ width: 16px;
948
+ height: 16px;
949
+ cursor: pointer;
950
+ line-height: 1;
951
+ z-index: 1;
952
+ }
892
953
  #chat-input.chat-input,
893
954
  #chat-input {
894
955
  width: 100%;
package/public/sw.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /* Agent UI service worker — cache shell assets only.
2
2
  * Never intercepts WebSocket or mutates API semantics beyond offline fallback.
3
3
  * Bump CACHE when shipping UI that must invalidate old shells. */
4
- const CACHE = 'agent-ui-shell-v7';
4
+ const CACHE = 'agent-ui-shell-v8';
5
5
  const PRECACHE = [
6
6
  '/',
7
7
  '/index.html',