dsh-files-native 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,9 @@
15
15
 
16
16
  - **图片**(PNG / JPEG / WebP / GIF)走官方草稿图栏(64px 缩略图,点击灯箱),发送后进模型视觉上下文。
17
17
  - **其它文件**落到会话工作区 `.dsh-uploads/`,同一条轨上用 64px 高可读横条卡(扩展名角标 + 文件名 + 大小)。
18
+ - **附件只跟随「立即发送」的消息**:忙碌时回车 = 纯文字排队,文件留在附件轨等下次空闲发送。
19
+ - **气泡附件卡**:消息发出后,文件卡渲染在用户气泡上方(与图片同列右对齐),注入提示行自动隐藏。
20
+ - **点击打开**:点气泡附件卡——装了 [better-sidebar](https://github.com/用户/dsh-better-sidebar) 走侧栏预览;未装则新页签打开(文本/图片/PDF 页签内展示,二进制自动下载)。
18
21
  - 发送后文件卡贴在用户气泡栈(与官方图同一列),作业回复不会插在提问和文件中间。
19
22
  - 发送成功即清输入框待发轨。回复结尾只画「产物」chip。
20
23
 
package/lib/client.js CHANGED
@@ -43,7 +43,6 @@ var NATIVE_IMAGE_TYPES = /* @__PURE__ */ new Set([
43
43
  var NATIVE_IMAGE_EXT = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "webp", "gif"]);
44
44
  var MAX_FILE_BYTES = 50 * 1024 * 1024;
45
45
  var MAX_FILES_PER_BATCH = 20;
46
- var UPLOAD_DIR = ".dsh-uploads";
47
46
  function isNativeImage(file) {
48
47
  const type = (file.type ?? "").toLowerCase();
49
48
  if (NATIVE_IMAGE_TYPES.has(type)) return true;
@@ -62,12 +61,6 @@ function formatSize(bytes) {
62
61
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
63
62
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
64
63
  }
65
- function isSafeRelPath(relPath) {
66
- if (relPath === "" || relPath.startsWith("/") || relPath.startsWith("\\")) return false;
67
- const parts = relPath.split(/[/\\]/);
68
- if (parts[0] !== UPLOAD_DIR) return false;
69
- return parts.every((part) => part !== "" && part !== "." && part !== "..");
70
- }
71
64
  function splitIntake(files) {
72
65
  const images = [];
73
66
  const others = [];
@@ -77,34 +70,6 @@ function splitIntake(files) {
77
70
  }
78
71
  return { images, others };
79
72
  }
80
- var NOTICE_LINE = /^- (.+?) — path="([^"]+)" size=(\d+)(?: type="([^"]*)")?/;
81
- function parseNoticeFiles(text) {
82
- const files = [];
83
- for (const line of text.split("\n")) {
84
- const match = NOTICE_LINE.exec(line.trim());
85
- if (match === null) continue;
86
- const relPath = match[2] ?? "";
87
- if (!isSafeRelPath(relPath)) continue;
88
- files.push({
89
- name: match[1] ?? "file",
90
- relPath,
91
- size: Number(match[3] ?? 0),
92
- mediaType: match[4] || "application/octet-stream"
93
- });
94
- }
95
- return files;
96
- }
97
- function contentText(content) {
98
- if (typeof content === "string") return content;
99
- if (!Array.isArray(content)) return "";
100
- const parts = [];
101
- for (const block of content) {
102
- if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
103
- parts.push(block.text);
104
- }
105
- }
106
- return parts.join("\n");
107
- }
108
73
 
109
74
  // src/store.ts
110
75
  var items = [];
@@ -125,8 +90,8 @@ function subscribe(fn) {
125
90
  listeners.delete(fn);
126
91
  };
127
92
  }
128
- function pending() {
129
- return items.slice();
93
+ function pendingFor(sessionId) {
94
+ return items.filter((item) => item.sessionId === sessionId);
130
95
  }
131
96
  function add(sessionId, item) {
132
97
  items.push({ ...item, sessionId: item.sessionId || sessionId });
@@ -162,14 +127,6 @@ function archiveSent(sessionId) {
162
127
  if (files.length > 0) emit();
163
128
  return files;
164
129
  }
165
- function archiveIfNoticeMatches(sessionId, notice) {
166
- if (notice.length === 0) return [];
167
- const pendingDone = doneFiles(sessionId);
168
- if (pendingDone.length === 0) return [];
169
- const paths = new Set(notice.map((file) => file.relPath));
170
- if (!pendingDone.some((file) => paths.has(file.relPath))) return [];
171
- return archiveSent(sessionId);
172
- }
173
130
  function lastSent(sessionId) {
174
131
  return sentBySession.get(sessionId) ?? [];
175
132
  }
@@ -261,6 +218,9 @@ var FR_CSS = `
261
218
  .fr-ctx-body{box-sizing:border-box;background:var(--dsw-alias-markdown-code-block);width:calc(100% - 22px);max-height:141px;
262
219
  color:var(--dsw-alias-label-tertiary);font:400 11px/16px var(--ds-font-family-code, ui-monospace,monospace);
263
220
  border:none;border-radius:8px;margin:4px 0 0 22px;padding:10px 16px 12px 12px;overflow:auto;white-space:pre-wrap}
221
+ [data-file-native-queue]{flex:none;display:inline-flex;align-items:center;gap:2px;height:20px;padding:0 7px;
222
+ border-radius:999px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-interactive-bg-hover);
223
+ font:500 11px/20px Inter, var(--dsw-font-family, system-ui);white-space:nowrap;pointer-events:none}
264
224
  `;
265
225
  var injected = false;
266
226
  function ensureStyles() {
@@ -344,7 +304,7 @@ function uid() {
344
304
  }
345
305
  async function uploadFile(sessionId, file) {
346
306
  const id = uid();
347
- const pending2 = {
307
+ const pending = {
348
308
  id,
349
309
  sessionId,
350
310
  name: file.name,
@@ -353,14 +313,14 @@ async function uploadFile(sessionId, file) {
353
313
  mediaType: file.type || "application/octet-stream",
354
314
  status: "uploading"
355
315
  };
356
- add(sessionId, pending2);
316
+ add(sessionId, pending);
357
317
  const url = `/plugins/file-native/upload?sessionId=${encodeURIComponent(sessionId)}&name=${encodeURIComponent(file.name)}&type=${encodeURIComponent(file.type)}`;
358
318
  try {
359
319
  const res = await fetch(url, { method: "POST", body: file });
360
320
  const body = await res.json();
361
321
  if (!res.ok || !body.ok || body.file === void 0) {
362
322
  patch(sessionId, id, { status: "error", error: body.error ?? "\u4E0A\u4F20\u5931\u8D25" });
363
- return { ...pending2, status: "error", error: body.error ?? "\u4E0A\u4F20\u5931\u8D25" };
323
+ return { ...pending, status: "error", error: body.error ?? "\u4E0A\u4F20\u5931\u8D25" };
364
324
  }
365
325
  patch(sessionId, id, {
366
326
  status: "done",
@@ -369,10 +329,10 @@ async function uploadFile(sessionId, file) {
369
329
  size: body.file.size,
370
330
  mediaType: body.file.mediaType
371
331
  });
372
- return { ...pending2, ...body.file, status: "done" };
332
+ return { ...pending, ...body.file, status: "done" };
373
333
  } catch {
374
334
  patch(sessionId, id, { status: "error", error: "\u7F51\u7EDC\u9519\u8BEF" });
375
- return { ...pending2, status: "error", error: "\u7F51\u7EDC\u9519\u8BEF" };
335
+ return { ...pending, status: "error", error: "\u7F51\u7EDC\u9519\u8BEF" };
376
336
  }
377
337
  }
378
338
  function intake(sessionId, files, onAddImages) {
@@ -436,9 +396,10 @@ function ImageTile({ item, onRemove, onOpen }) {
436
396
  }
437
397
  function FileTile({ item, sessionId }) {
438
398
  const remove2 = () => {
439
- remove(sessionId, item.id);
399
+ const sid = item.sessionId || sessionId;
400
+ remove(sid, item.id);
440
401
  if (item.relPath) {
441
- void fetch(`/plugins/file-native/remove?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(item.relPath)}`, { method: "POST" });
402
+ void fetch(`/plugins/file-native/remove?sessionId=${encodeURIComponent(sid)}&path=${encodeURIComponent(item.relPath)}`, { method: "POST" });
442
403
  }
443
404
  };
444
405
  const meta = item.status === "uploading" ? "\u4E0A\u4F20\u4E2D\u2026" : item.status === "error" ? item.error ?? "\u5931\u8D25" : formatSize(item.size);
@@ -462,7 +423,7 @@ function FileTile({ item, sessionId }) {
462
423
  function FileRail(props) {
463
424
  ensureStyles();
464
425
  const sessionId = String(props.sessionId ?? "");
465
- const [files, setFiles] = (0, import_react2.useState)(() => pending());
426
+ const [files, setFiles] = (0, import_react2.useState)(() => pendingFor(sessionId));
466
427
  const [, setVersion] = (0, import_react2.useState)(() => version());
467
428
  const [preview, setPreview] = (0, import_react2.useState)(null);
468
429
  const [edges, setEdges] = (0, import_react2.useState)({ left: false, right: false });
@@ -470,12 +431,12 @@ function FileRail(props) {
470
431
  const countRef = (0, import_react2.useRef)(0);
471
432
  (0, import_react2.useEffect)(() => {
472
433
  const sync = () => {
473
- setFiles(pending());
434
+ setFiles(pendingFor(sessionId));
474
435
  setVersion(version());
475
436
  };
476
437
  sync();
477
438
  return subscribe(sync);
478
- }, []);
439
+ }, [sessionId]);
479
440
  const updateEdges = () => {
480
441
  const el = rowRef.current;
481
442
  if (el === null) return;
@@ -581,7 +542,9 @@ var import_react_dom2 = require("react-dom");
581
542
  var owner = null;
582
543
  var lastSessionId = "";
583
544
  var dragDepth = 0;
545
+ var generation2 = 0;
584
546
  var listeners2 = /* @__PURE__ */ new Set();
547
+ var queuedImages = /* @__PURE__ */ new Map();
585
548
  function emit2() {
586
549
  for (const fn of [...listeners2]) fn();
587
550
  }
@@ -591,14 +554,42 @@ function subscribeLive(fn) {
591
554
  listeners2.delete(fn);
592
555
  };
593
556
  }
557
+ function flushQueuedImages() {
558
+ if (!owner?.onAddImages) return;
559
+ const sid = owner.sessionId;
560
+ const files = (queuedImages.get(sid) ?? []).concat(sid ? queuedImages.get("") ?? [] : []);
561
+ queuedImages.delete(sid);
562
+ if (sid) queuedImages.delete("");
563
+ if (files.length === 0) return;
564
+ owner.onAddImages(files);
565
+ }
594
566
  function setLiveOwner(next) {
567
+ generation2 += 1;
595
568
  owner = next;
596
569
  if (next?.sessionId) lastSessionId = next.sessionId;
597
570
  emit2();
571
+ if (next) flushQueuedImages();
572
+ return generation2;
573
+ }
574
+ function clearLiveOwner(token) {
575
+ if (token !== generation2) return;
576
+ owner = null;
577
+ emit2();
598
578
  }
599
579
  function getLiveOwner() {
600
580
  return owner;
601
581
  }
582
+ function addImages(sessionId, files) {
583
+ if (files.length === 0) return;
584
+ const sid = sessionId || currentSessionId();
585
+ if (owner?.onAddImages && (!owner.sessionId || owner.sessionId === sid)) {
586
+ owner.onAddImages(files);
587
+ return;
588
+ }
589
+ const key = sid || owner?.sessionId || lastSessionId;
590
+ const prev = queuedImages.get(key) ?? [];
591
+ queuedImages.set(key, prev.concat(files));
592
+ }
602
593
  function setDragDepth(depth) {
603
594
  const next = Math.max(0, depth);
604
595
  if (next === dragDepth) return;
@@ -696,11 +687,9 @@ function PaperclipButton(props) {
696
687
  const onChange = (event) => {
697
688
  const files = Array.from(event.currentTarget.files ?? []);
698
689
  event.currentTarget.value = "";
699
- const live = getLiveOwner();
700
- const sid = String(props.sessionId || live?.sessionId || currentSessionId());
690
+ const sid = String(props.sessionId || currentSessionId());
701
691
  if (files.length === 0) return;
702
- intakeFiles(sid, files, live?.onAddImages ?? (() => {
703
- }));
692
+ intakeFiles(sid, files, (images) => addImages(sid, images));
704
693
  };
705
694
  return (0, import_react5.createElement)(
706
695
  "span",
@@ -809,6 +798,10 @@ function producedPathsOf(owner2) {
809
798
  // src/context.tsx
810
799
  var import_react7 = require("react");
811
800
  var import_react_dom3 = require("react-dom");
801
+ function isFileNoticeRow(el) {
802
+ const text = el.textContent ?? "";
803
+ return text.includes("\u{1F4CE} \u9644\u4EF6") && text.includes("\u4E2A\u6587\u4EF6");
804
+ }
812
805
  function FileCard({ file, onOpen }) {
813
806
  return (0, import_react7.createElement)(
814
807
  "button",
@@ -816,14 +809,14 @@ function FileCard({ file, onOpen }) {
816
809
  type: "button",
817
810
  className: "fr-msg-card",
818
811
  title: file.relPath,
819
- onClick: () => onOpen?.(file.relPath)
812
+ onClick: () => onOpen?.(file)
820
813
  },
821
814
  (0, import_react7.createElement)("span", { className: "fr-ext" }, extOf(file.name)),
822
815
  (0, import_react7.createElement)(
823
816
  "span",
824
817
  { className: "fr-copy" },
825
818
  (0, import_react7.createElement)("span", { className: "fr-name" }, file.name),
826
- (0, import_react7.createElement)("span", { className: "fr-meta" }, formatSize(file.size))
819
+ file.size > 0 ? (0, import_react7.createElement)("span", { className: "fr-meta" }, formatSize(file.size)) : null
827
820
  )
828
821
  );
829
822
  }
@@ -831,15 +824,11 @@ function userStackOf(el) {
831
824
  if (!(el instanceof HTMLElement)) return null;
832
825
  const kind = el.getAttribute("data-chat-flow-kind");
833
826
  if (kind !== "user" && kind !== "steering") return null;
834
- const row = el.querySelector("[data-time-hover-root]");
835
- const stack = row?.firstElementChild;
836
- return stack instanceof HTMLElement ? stack : null;
837
- }
838
- function findUserStack(anchor) {
839
- const flow = anchor?.closest('[data-chat-flow-kind="context"]');
840
- if (flow === null) return null;
841
- let next = flow.nextElementSibling;
842
- let prev = flow.previousElementSibling;
827
+ return el.querySelector('[class*="userStack"]');
828
+ }
829
+ function findUserStack(notice) {
830
+ let next = notice.nextElementSibling;
831
+ let prev = notice.previousElementSibling;
843
832
  for (let hop = 0; hop < 16; hop += 1) {
844
833
  const stack = userStackOf(next) ?? userStackOf(prev);
845
834
  if (stack !== null) return stack;
@@ -848,98 +837,106 @@ function findUserStack(anchor) {
848
837
  }
849
838
  return null;
850
839
  }
851
- function alreadyPlaced(el, stack, before) {
852
- if (el.parentElement !== stack) return false;
853
- if (before === el) return true;
854
- return el.nextSibling === before;
855
- }
856
- function placeHost2(stack) {
857
- let el = stack.querySelector("[data-file-native-msg]");
840
+ function placeHostInStack(stack) {
841
+ let el = stack.querySelector(":scope > [data-file-native-msg]");
858
842
  if (el === null) {
859
843
  el = document.createElement("div");
860
844
  el.dataset.fileNativeMsg = "";
861
845
  el.className = "fr-msg-row";
846
+ stack.insertBefore(el, stack.firstChild);
862
847
  }
863
- const gallery = stack.querySelector("[data-align]");
864
- const after = gallery instanceof HTMLElement ? gallery.closest('[data-slot="conversation.message.images"]') ?? gallery : null;
865
- const before = after?.nextSibling ?? stack.firstChild;
866
- if (!alreadyPlaced(el, stack, before)) {
867
- stack.insertBefore(el, before);
848
+ return el;
849
+ }
850
+ function placeHostInRow(notice) {
851
+ let el = notice.querySelector(":scope > [data-file-native-msg]");
852
+ if (el === null) {
853
+ el = document.createElement("div");
854
+ el.dataset.fileNativeMsg = "";
855
+ el.style.cssText = "flex:0 0 100%;display:flex;flex-wrap:wrap;gap:10px;justify-content:flex-end;padding:2px 44px 8px 22px;width:100%;box-sizing:border-box";
856
+ notice.appendChild(el);
868
857
  }
869
858
  return el;
870
859
  }
871
- function AttachToUserBubble(props) {
872
- const anchorRef = (0, import_react7.useRef)(null);
873
- const [host, setHost] = (0, import_react7.useState)(null);
874
- const noticeKey = props.files.map((file) => file.relPath).join("\n");
875
- (0, import_react7.useEffect)(() => {
876
- const sid = currentSessionId();
877
- if (sid) archiveIfNoticeMatches(sid, props.files);
878
- }, [noticeKey]);
860
+ function MessageCards(props) {
861
+ ensureStyles();
862
+ const [hosted, setHosted] = (0, import_react7.useState)([]);
863
+ const sessionKey = (0, import_react7.useRef)("");
864
+ const lastCount = (0, import_react7.useRef)(-1);
865
+ const cachedFiles = (0, import_react7.useRef)(null);
879
866
  (0, import_react7.useEffect)(() => {
867
+ if (sessionKey.current !== (props.sessionId ?? "")) {
868
+ sessionKey.current = props.sessionId ?? "";
869
+ lastCount.current = -1;
870
+ cachedFiles.current = null;
871
+ }
880
872
  let cancelled = false;
881
- let tries = 0;
882
873
  const tick = () => {
883
874
  if (cancelled) return;
884
- const stack = findUserStack(anchorRef.current);
885
- if (stack !== null) {
886
- const el = placeHost2(stack);
887
- setHost((prev) => prev === el ? prev : el);
888
- return;
889
- }
890
- tries += 1;
891
- if (tries < 30) window.setTimeout(tick, 50);
875
+ const run = async () => {
876
+ const rows = [...document.querySelectorAll('[data-chat-flow-kind="context"]')].filter((el) => isFileNoticeRow(el));
877
+ if (rows.length === 0) {
878
+ if (lastCount.current !== 0) {
879
+ lastCount.current = 0;
880
+ setHosted([]);
881
+ }
882
+ return;
883
+ }
884
+ if (rows.length !== lastCount.current) {
885
+ const sid = props.sessionId ?? "";
886
+ if (sid !== "") {
887
+ try {
888
+ const res = await fetch(`/plugins/file-native/last?sessionId=${encodeURIComponent(sid)}`);
889
+ const body = await res.json();
890
+ cachedFiles.current = Array.isArray(body.files) ? body.files : [];
891
+ } catch {
892
+ cachedFiles.current = cachedFiles.current ?? [];
893
+ }
894
+ }
895
+ lastCount.current = rows.length;
896
+ }
897
+ const files = cachedFiles.current ?? [];
898
+ if (files.length === 0) return;
899
+ const latest = rows[rows.length - 1];
900
+ const stack = findUserStack(latest);
901
+ const host = stack !== null ? placeHostInStack(stack) : placeHostInRow(latest);
902
+ if (stack !== null) {
903
+ if (latest.querySelector("[data-file-native-anchor]") === null) {
904
+ const anchor = document.createElement("span");
905
+ anchor.dataset.fileNativeAnchor = "";
906
+ anchor.setAttribute("aria-hidden", "true");
907
+ latest.appendChild(anchor);
908
+ }
909
+ }
910
+ setHosted((prev) => {
911
+ if (prev.length === 1 && prev[0].host === host) return prev;
912
+ return [{ host, files }];
913
+ });
914
+ };
915
+ void run().catch(() => {
916
+ }).then(() => {
917
+ if (!cancelled) window.setTimeout(tick, 300);
918
+ });
892
919
  };
893
920
  tick();
894
921
  return () => {
895
922
  cancelled = true;
896
923
  };
897
- }, []);
898
- const gallery = (0, import_react7.createElement)(
899
- "div",
900
- { className: "fr-msg-row-inner" },
901
- ...props.files.map((file) => (0, import_react7.createElement)(FileCard, { key: file.relPath, file, onOpen: props.openFile }))
902
- );
903
- return (0, import_react7.createElement)("span", {
904
- ref: anchorRef,
905
- "data-file-native-anchor": true,
906
- "aria-hidden": true
907
- }, host !== null && host.isConnected ? (0, import_react_dom3.createPortal)(gallery, host) : null);
908
- }
909
- function OtherContext({ node }) {
910
- const [open, setOpen] = (0, import_react7.useState)(false);
911
- const data = node.data ?? {};
912
- const text = contentText(data.content);
913
- const summary = data.source?.summary || data.provenance?.label || "\u4E0A\u4E0B\u6587\u6CE8\u5165";
914
- const source = data.source?.plugin || data.provenance?.label || "";
924
+ }, [props.sessionId]);
925
+ if (hosted.length === 0) return null;
915
926
  return (0, import_react7.createElement)(
916
- "div",
917
- { className: "fr-ctx", "data-open": open || void 0 },
918
- (0, import_react7.createElement)(
919
- "button",
920
- {
921
- type: "button",
922
- className: "fr-ctx-head",
923
- onClick: () => setOpen((value) => !value)
924
- },
925
- (0, import_react7.createElement)("span", { className: "fr-ctx-title" }, "\u4E0A\u4E0B\u6587\u6CE8\u5165"),
926
- source ? (0, import_react7.createElement)("span", { className: "fr-ctx-src" }, source) : null,
927
- (0, import_react7.createElement)("span", { className: "fr-ctx-sum" }, summary)
928
- ),
929
- open ? (0, import_react7.createElement)("pre", { className: "fr-ctx-body" }, text) : null
927
+ "span",
928
+ { "aria-hidden": true },
929
+ ...hosted.map((item, i) => (0, import_react_dom3.createPortal)(
930
+ (0, import_react7.createElement)(
931
+ "div",
932
+ { className: "fr-msg-row-inner" },
933
+ ...item.files.map((file) => (0, import_react7.createElement)(FileCard, { key: file.relPath, file, onOpen: props.onOpen }))
934
+ ),
935
+ item.host,
936
+ `file-native-cards-${i}`
937
+ ))
930
938
  );
931
939
  }
932
- function ContextNodeView(props) {
933
- ensureStyles();
934
- const node = props.node ?? {};
935
- const source = node.data?.source;
936
- if (source?.kind === "plugin" && source.plugin === "file-native") {
937
- const files = parseNoticeFiles(contentText(node.data?.content));
938
- if (files.length === 0) return null;
939
- return (0, import_react7.createElement)(AttachToUserBubble, { files, openFile: props.openFile });
940
- }
941
- return (0, import_react7.createElement)(OtherContext, { node });
942
- }
943
940
 
944
941
  // src/intercept.ts
945
942
  function looksLikeFileDrag(transfer) {
@@ -1031,38 +1028,79 @@ function installFileIntercept(handlers) {
1031
1028
 
1032
1029
  // src/client.ts
1033
1030
  var name = "file-native";
1034
- var inject = ["slots"];
1031
+ var inject = ["slots", "betterSidebar"];
1035
1032
  function RailSlot(props) {
1036
1033
  ensureStyles();
1037
1034
  const sessionId = resolveSessionId(props);
1038
- setLiveOwner({
1039
- attachments: props.attachments,
1040
- canAcceptDrop: props.canAcceptDrop,
1041
- onAddImages: props.onAddImages,
1042
- onRemoveImage: props.onRemoveImage,
1043
- sessionId,
1044
- dropLimits: props.dropLimits
1035
+ rememberSessionId(sessionId);
1036
+ const tokenRef = (0, import_react8.useRef)(0);
1037
+ (0, import_react8.useLayoutEffect)(() => {
1038
+ tokenRef.current = setLiveOwner({
1039
+ attachments: props.attachments,
1040
+ canAcceptDrop: props.canAcceptDrop,
1041
+ onAddImages: props.onAddImages,
1042
+ onRemoveImage: props.onRemoveImage,
1043
+ sessionId,
1044
+ dropLimits: props.dropLimits
1045
+ });
1045
1046
  });
1046
- (0, import_react8.useEffect)(() => () => setLiveOwner(null), []);
1047
+ (0, import_react8.useLayoutEffect)(() => () => clearLiveOwner(tokenRef.current), []);
1047
1048
  return null;
1048
1049
  }
1050
+ var chatCardOpener;
1049
1051
  function PickerSlot(props) {
1050
1052
  const sessionId = resolveSessionId(props);
1051
1053
  rememberSessionId(sessionId);
1052
- const phase = String(props.input?.phase ?? "");
1053
- const draft = String(props.input?.draft ?? "");
1054
- const imageCount = props.input?.imageIds?.length ?? 0;
1055
- const prev = (0, import_react8.useRef)({ phase, draft, imageCount });
1054
+ const running = Boolean(props.useSession?.((state) => state.running));
1055
+ const inputActions = props.inputActions;
1056
+ const submitAction = inputActions?.submit;
1057
+ const kitSig = `inputActions=${props.inputActions ? "y" : "n"} useSession=${props.useSession ? "y" : "n"}`;
1056
1058
  (0, import_react8.useEffect)(() => {
1057
- const sid = sessionId || currentSessionId();
1058
- const was = prev.current;
1059
- const entering = phase === "submitting" || phase === "claimed";
1060
- const leaving = (was.phase === "submitting" || was.phase === "claimed") && phase === "plain";
1061
- const sentPlain = was.phase !== "plain" && phase === "plain" && draft.trim() === "" && imageCount === 0;
1062
- if (sid && (entering || leaving || sentPlain)) archiveSent(sid);
1063
- prev.current = { phase, draft, imageCount };
1064
- }, [phase, draft, imageCount, sessionId]);
1065
- return (0, import_react8.createElement)(PaperclipButton, { sessionId });
1059
+ console.info(`[file-native] kit: ${kitSig}`);
1060
+ }, [kitSig]);
1061
+ const submitWithFiles = (ev, sid) => {
1062
+ const submitFn = inputActions?.submit;
1063
+ archiveSent(sid);
1064
+ if (submitFn === void 0) {
1065
+ void fetch(`/plugins/file-native/arm?sessionId=${encodeURIComponent(sid)}`, { method: "POST" }).catch(() => {
1066
+ });
1067
+ return;
1068
+ }
1069
+ ev.preventDefault();
1070
+ ev.stopPropagation();
1071
+ void fetch(`/plugins/file-native/arm?sessionId=${encodeURIComponent(sid)}`, { method: "POST" }).catch(() => {
1072
+ }).then(() => submitFn());
1073
+ };
1074
+ (0, import_react8.useEffect)(() => {
1075
+ const onKeyDown = (ev) => {
1076
+ if (ev.key !== "Enter" || ev.shiftKey || ev.isComposing || ev.altKey || ev.ctrlKey || ev.metaKey) return;
1077
+ if (running) return;
1078
+ const sid = sessionId || currentSessionId();
1079
+ if (sid === "" || doneFiles(sid).length === 0) return;
1080
+ submitWithFiles(ev, sid);
1081
+ };
1082
+ document.addEventListener("keydown", onKeyDown, true);
1083
+ return () => document.removeEventListener("keydown", onKeyDown, true);
1084
+ }, [running, inputActions, sessionId]);
1085
+ (0, import_react8.useEffect)(() => {
1086
+ const onClick = (ev) => {
1087
+ if (running) return;
1088
+ const button = ev.target instanceof Element ? ev.target.closest("button") : null;
1089
+ if (button === null) return;
1090
+ if (!/[a-zA-Z0-9_-]*primary/.test(button.className)) return;
1091
+ const sid = sessionId || currentSessionId();
1092
+ if (sid === "" || doneFiles(sid).length === 0) return;
1093
+ submitWithFiles(ev, sid);
1094
+ };
1095
+ document.addEventListener("click", onClick, true);
1096
+ return () => document.removeEventListener("click", onClick, true);
1097
+ }, [running, inputActions, sessionId]);
1098
+ return (0, import_react8.createElement)(
1099
+ "span",
1100
+ null,
1101
+ (0, import_react8.createElement)(PaperclipButton, { sessionId }),
1102
+ (0, import_react8.createElement)(MessageCards, { sessionId, onOpen: (file) => chatCardOpener?.(sessionId || currentSessionId(), file.relPath, file.name) })
1103
+ );
1066
1104
  }
1067
1105
  function TailSlot(props) {
1068
1106
  return (0, import_react8.createElement)(UploadedTail, {
@@ -1082,36 +1120,56 @@ function installLiveIntercept() {
1082
1120
  canAccept: () => true,
1083
1121
  onDepth: (depth) => setDragDepth(depth),
1084
1122
  onFiles: (files) => {
1085
- const live = getLiveOwner();
1086
- const sessionId = live?.sessionId || currentSessionId();
1087
- const addImages = live?.onAddImages ?? (() => {
1088
- });
1089
- intakeFiles(sessionId, files, addImages);
1123
+ const sessionId = currentSessionId();
1124
+ intakeFiles(sessionId, files, (images) => addImages(sessionId, images));
1090
1125
  }
1091
1126
  });
1092
1127
  }
1128
+ var INSTANCE = (() => {
1129
+ const w = globalThis;
1130
+ w.__fileNativeInstances = (w.__fileNativeInstances ?? 0) + 1;
1131
+ const n = w.__fileNativeInstances;
1132
+ console.warn(`[file-native] apply #${n}`);
1133
+ return n;
1134
+ })();
1093
1135
  function apply(ctx) {
1094
1136
  ctx.effect(() => installLiveIntercept(), "file-native: document intercept");
1095
- ctx.slots.inject("conversation.input.attachments", () => ctx.slots.register({
1137
+ const sidebar = ctx.betterSidebar;
1138
+ if (sidebar !== void 0 && sidebar.features.includes("openFile")) {
1139
+ chatCardOpener = (sid, relPath, name2) => sidebar.openFile({ sessionId: sid }, relPath, name2);
1140
+ } else {
1141
+ chatCardOpener = (sid, relPath) => {
1142
+ window.open(`/plugins/file-native/file?sessionId=${encodeURIComponent(sid)}&path=${encodeURIComponent(relPath)}`, "_blank");
1143
+ };
1144
+ }
1145
+ const base = -(1e3 + INSTANCE * 10);
1146
+ const safeInject = (slot, registerFn) => {
1147
+ ctx.slots.inject(slot, () => {
1148
+ try {
1149
+ return registerFn();
1150
+ } catch (error) {
1151
+ console.warn(`[file-native] skip ${slot} registration:`, error);
1152
+ return () => {
1153
+ };
1154
+ }
1155
+ });
1156
+ };
1157
+ safeInject("conversation.input.attachments", () => ctx.slots.register({
1096
1158
  name: "conversation.input.attachments",
1097
- priority: -1
1159
+ priority: base
1098
1160
  }, RailSlot));
1099
- ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
1161
+ safeInject("conversation.input.left", () => ctx.slots.register({
1100
1162
  name: "conversation.input.left",
1101
1163
  id: "file-native-picker",
1102
- order: 0
1164
+ order: 0,
1165
+ priority: base
1103
1166
  }, PickerSlot));
1104
- ctx.slots.inject("conversation.chat.node", () => ctx.slots.register({
1105
- name: "conversation.chat.node",
1106
- key: "context",
1107
- priority: -1
1108
- }, ContextNodeView));
1109
- ctx.slots.inject("conversation.chat.turnTail", () => ctx.slots.register({
1167
+ safeInject("conversation.chat.turnTail", () => ctx.slots.register({
1110
1168
  name: "conversation.chat.turnTail",
1111
- priority: -1,
1112
- select: selectTail
1169
+ select: selectTail,
1170
+ priority: base
1113
1171
  }, TailSlot));
1114
- ctx.logger?.info?.("[file-native] client loaded");
1172
+ console.info(`[file-native] client loaded (instance #${INSTANCE})`);
1115
1173
  }
1116
1174
 
1117
1175
  return module.exports;
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/index.ts
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { createWriteStream } from "node:fs";
4
- import { mkdir, rename, rm, stat } from "node:fs/promises";
4
+ import { mkdir, readFile, rename, rm, stat } from "node:fs/promises";
5
5
  import { dirname, join, resolve, sep } from "node:path";
6
6
 
7
7
  // src/lib.ts
@@ -41,6 +41,7 @@ var name = "file-native";
41
41
  var inject = ["webServer", "sessions"];
42
42
  var pending = /* @__PURE__ */ new Map();
43
43
  var lastSent = /* @__PURE__ */ new Map();
44
+ var armed = /* @__PURE__ */ new Set();
44
45
  function sessionCwd(ctx, sessionId, clientCwd) {
45
46
  const headerCwd = ctx.sessions.get(sessionId)?.header?.cwd;
46
47
  if (headerCwd) return headerCwd;
@@ -127,12 +128,12 @@ function registerRoutes(ctx, webServer) {
127
128
  const bytes = await readBody(req, MAX_FILE_BYTES);
128
129
  if (bytes.length === 0) return json(res, 400, { ok: false, error: "empty" });
129
130
  const cwd = sessionCwd(ctx, sessionId, q.get("cwd") ?? void 0);
130
- const file = await writeUpload(cwd, fileName, bytes);
131
- file.mediaType = q.get("type") || file.mediaType;
131
+ const file2 = await writeUpload(cwd, fileName, bytes);
132
+ file2.mediaType = q.get("type") || file2.mediaType;
132
133
  const list = pending.get(sessionId) ?? [];
133
- list.push(file);
134
+ list.push(file2);
134
135
  pending.set(sessionId, list);
135
- json(res, 200, { ok: true, file });
136
+ json(res, 200, { ok: true, file: file2 });
136
137
  } catch (error) {
137
138
  const code = error.code === "too-large" ? 413 : 400;
138
139
  ctx.logger.warn("[file-native] upload failed: %o", error);
@@ -168,10 +169,68 @@ function registerRoutes(ctx, webServer) {
168
169
  json(res, 200, { ok: true, files: lastSent.get(sessionId) ?? [] });
169
170
  }
170
171
  });
172
+ const arm = webServer.register({
173
+ kind: "exact",
174
+ path: "/plugins/file-native/arm",
175
+ handler: (req, res) => {
176
+ if (!isLoopback(req)) return json(res, 403, { ok: false, error: "forbidden" });
177
+ if (req.method !== "POST") return json(res, 405, { ok: false, error: "method" });
178
+ const sessionId = query(req).get("sessionId") ?? "";
179
+ if (sessionId === "") return json(res, 400, { ok: false, error: "sessionId" });
180
+ armed.add(sessionId);
181
+ json(res, 200, { ok: true });
182
+ }
183
+ });
184
+ const file = webServer.register({
185
+ kind: "exact",
186
+ path: "/plugins/file-native/file",
187
+ handler: async (req, res) => {
188
+ if (!isLoopback(req)) return json(res, 403, { ok: false, error: "forbidden" });
189
+ if (req.method !== "GET") return json(res, 405, { ok: false, error: "method" });
190
+ const q = query(req);
191
+ const sessionId = q.get("sessionId") ?? "";
192
+ const relPath = q.get("path") ?? "";
193
+ if (sessionId === "" || !isSafeRelPath(relPath)) return json(res, 400, { ok: false, error: "bad-path" });
194
+ try {
195
+ const cwd = sessionCwd(ctx, sessionId);
196
+ const abs = resolve(cwd, relPath);
197
+ const info = await stat(abs);
198
+ if (!info.isFile()) throw new Error("not-file");
199
+ const bytes = await readFile(abs);
200
+ const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
201
+ const types = {
202
+ md: "text/markdown; charset=utf-8",
203
+ txt: "text/plain; charset=utf-8",
204
+ json: "application/json; charset=utf-8",
205
+ csv: "text/csv; charset=utf-8",
206
+ pdf: "application/pdf",
207
+ png: "image/png",
208
+ jpg: "image/jpeg",
209
+ jpeg: "image/jpeg",
210
+ gif: "image/gif",
211
+ webp: "image/webp",
212
+ svg: "image/svg+xml",
213
+ html: "text/html; charset=utf-8",
214
+ xml: "application/xml"
215
+ };
216
+ const name2 = relPath.replace(/^.*[/\\]/, "");
217
+ res.writeHead(200, {
218
+ "content-type": types[ext] ?? "application/octet-stream",
219
+ "content-length": String(bytes.length),
220
+ "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(name2)}`
221
+ });
222
+ res.end(bytes);
223
+ } catch {
224
+ json(res, 404, { ok: false, error: "not-found" });
225
+ }
226
+ }
227
+ });
171
228
  return () => {
172
229
  upload();
173
230
  remove();
174
231
  last();
232
+ arm();
233
+ file();
175
234
  };
176
235
  }
177
236
  function apply(ctx) {
@@ -205,20 +264,25 @@ function apply(ctx) {
205
264
  if (decision?.kind === "reject") return decision;
206
265
  const agent = payload.agent;
207
266
  const sessionId = String(agent?.id ?? "");
267
+ if (!Array.isArray(decision.messages) || decision.messages.length === 0) return decision;
268
+ const lastMessage = decision.messages[decision.messages.length - 1];
269
+ if (String(lastMessage?.role) !== "user") return decision;
208
270
  const files = takePending(sessionId);
209
- if (files.length === 0 || !Array.isArray(decision.messages) || decision.messages.length === 0) {
210
- return decision;
211
- }
271
+ if (!armed.has(sessionId)) return decision;
272
+ armed.delete(sessionId);
273
+ const deliver = files;
274
+ if (deliver.length === 0) return decision;
275
+ lastSent.set(sessionId, deliver);
212
276
  const lastIndex = decision.messages.length - 1;
213
277
  const notice = {
214
278
  id: `file-native-${String(decision.messages[lastIndex]?.id ?? randomUUID())}`,
215
279
  role: "user",
216
- content: [{ type: "text", text: fileListText(files) }],
280
+ content: [{ type: "text", text: fileListText(deliver) }],
217
281
  source: {
218
282
  kind: "plugin",
219
283
  plugin: "file-native",
220
284
  form: "notice",
221
- summary: `\u{1F4CE} \u9644\u4EF6 ${files.length} \u4E2A\u6587\u4EF6`
285
+ summary: `\u{1F4CE} \u9644\u4EF6 ${deliver.length} \u4E2A\u6587\u4EF6`
222
286
  }
223
287
  };
224
288
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-files-native",
3
3
  "description": "DSH 插件:接近原生质感的附件上传——拖入/粘贴/回形针,图片缩略图与文件卡片混排在官方附件栏",
4
- "version": "0.1.2",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -56,4 +56,4 @@
56
56
  "esbuild": "^0.28.2",
57
57
  "typescript": "^5.6.0"
58
58
  }
59
- }
59
+ }