dsh-files-native 0.1.3 → 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 = [];
@@ -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() {
@@ -484,8 +444,7 @@ function FileRail(props) {
484
444
  const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
485
445
  setEdges((prev) => prev.left === left && prev.right === right ? prev : { left, right });
486
446
  };
487
- const visibleFiles = files.filter((item) => item.sessionId === sessionId);
488
- const itemCount = props.attachments.length + visibleFiles.length;
447
+ const itemCount = props.attachments.length + files.length;
489
448
  (0, import_react2.useLayoutEffect)(() => {
490
449
  const grew = countRef.current !== 0 && itemCount > countRef.current;
491
450
  countRef.current = itemCount;
@@ -521,7 +480,7 @@ function FileRail(props) {
521
480
  el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: "smooth" });
522
481
  };
523
482
  const hasImages = props.attachments.length > 0;
524
- const hasFiles = visibleFiles.length > 0;
483
+ const hasFiles = files.length > 0;
525
484
  if (!hasImages && !hasFiles) return null;
526
485
  return (0, import_react2.createElement)(
527
486
  "div",
@@ -539,7 +498,7 @@ function FileRail(props) {
539
498
  onRemove: () => props.onRemoveImage(item.id),
540
499
  onOpen: () => setPreview(item)
541
500
  })),
542
- ...visibleFiles.map((item) => (0, import_react2.createElement)(FileTile, { key: item.id, item, sessionId }))
501
+ ...files.map((item) => (0, import_react2.createElement)(FileTile, { key: item.id, item, sessionId }))
543
502
  ),
544
503
  edges.right ? (0, import_react2.createElement)("button", { type: "button", className: "fr-arrow fr-arrow-right", "aria-label": "\u5411\u53F3", onClick: () => page(1) }, (0, import_react2.createElement)(IconChevronRight)) : null
545
504
  ),
@@ -629,7 +588,7 @@ function addImages(sessionId, files) {
629
588
  }
630
589
  const key = sid || owner?.sessionId || lastSessionId;
631
590
  const prev = queuedImages.get(key) ?? [];
632
- queuedImages.set(key, prev.concat([...files]));
591
+ queuedImages.set(key, prev.concat(files));
633
592
  }
634
593
  function setDragDepth(depth) {
635
594
  const next = Math.max(0, depth);
@@ -839,6 +798,10 @@ function producedPathsOf(owner2) {
839
798
  // src/context.tsx
840
799
  var import_react7 = require("react");
841
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
+ }
842
805
  function FileCard({ file, onOpen }) {
843
806
  return (0, import_react7.createElement)(
844
807
  "button",
@@ -846,14 +809,14 @@ function FileCard({ file, onOpen }) {
846
809
  type: "button",
847
810
  className: "fr-msg-card",
848
811
  title: file.relPath,
849
- onClick: () => onOpen?.(file.relPath)
812
+ onClick: () => onOpen?.(file)
850
813
  },
851
814
  (0, import_react7.createElement)("span", { className: "fr-ext" }, extOf(file.name)),
852
815
  (0, import_react7.createElement)(
853
816
  "span",
854
817
  { className: "fr-copy" },
855
818
  (0, import_react7.createElement)("span", { className: "fr-name" }, file.name),
856
- (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
857
820
  )
858
821
  );
859
822
  }
@@ -861,15 +824,11 @@ function userStackOf(el) {
861
824
  if (!(el instanceof HTMLElement)) return null;
862
825
  const kind = el.getAttribute("data-chat-flow-kind");
863
826
  if (kind !== "user" && kind !== "steering") return null;
864
- const row = el.querySelector("[data-time-hover-root]");
865
- const stack = row?.firstElementChild;
866
- return stack instanceof HTMLElement ? stack : null;
867
- }
868
- function findUserStack(anchor) {
869
- const flow = anchor?.closest('[data-chat-flow-kind="context"]');
870
- if (flow === null) return null;
871
- let next = flow.nextElementSibling;
872
- 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;
873
832
  for (let hop = 0; hop < 16; hop += 1) {
874
833
  const stack = userStackOf(next) ?? userStackOf(prev);
875
834
  if (stack !== null) return stack;
@@ -878,98 +837,106 @@ function findUserStack(anchor) {
878
837
  }
879
838
  return null;
880
839
  }
881
- function alreadyPlaced(el, stack, before) {
882
- if (el.parentElement !== stack) return false;
883
- if (before === el) return true;
884
- return el.nextSibling === before;
885
- }
886
- function placeHost2(stack) {
887
- let el = stack.querySelector("[data-file-native-msg]");
840
+ function placeHostInStack(stack) {
841
+ let el = stack.querySelector(":scope > [data-file-native-msg]");
888
842
  if (el === null) {
889
843
  el = document.createElement("div");
890
844
  el.dataset.fileNativeMsg = "";
891
845
  el.className = "fr-msg-row";
846
+ stack.insertBefore(el, stack.firstChild);
892
847
  }
893
- const gallery = stack.querySelector("[data-align]");
894
- const after = gallery instanceof HTMLElement ? gallery.closest('[data-slot="conversation.message.images"]') ?? gallery : null;
895
- const before = after?.nextSibling ?? stack.firstChild;
896
- if (!alreadyPlaced(el, stack, before)) {
897
- 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);
898
857
  }
899
858
  return el;
900
859
  }
901
- function AttachToUserBubble(props) {
902
- const anchorRef = (0, import_react7.useRef)(null);
903
- const [host, setHost] = (0, import_react7.useState)(null);
904
- const noticeKey = props.files.map((file) => file.relPath).join("\n");
905
- (0, import_react7.useEffect)(() => {
906
- const sid = currentSessionId();
907
- if (sid) archiveIfNoticeMatches(sid, props.files);
908
- }, [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);
909
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
+ }
910
872
  let cancelled = false;
911
- let tries = 0;
912
873
  const tick = () => {
913
874
  if (cancelled) return;
914
- const stack = findUserStack(anchorRef.current);
915
- if (stack !== null) {
916
- const el = placeHost2(stack);
917
- setHost((prev) => prev === el ? prev : el);
918
- return;
919
- }
920
- tries += 1;
921
- 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
+ });
922
919
  };
923
920
  tick();
924
921
  return () => {
925
922
  cancelled = true;
926
923
  };
927
- }, []);
928
- const gallery = (0, import_react7.createElement)(
929
- "div",
930
- { className: "fr-msg-row-inner" },
931
- ...props.files.map((file) => (0, import_react7.createElement)(FileCard, { key: file.relPath, file, onOpen: props.openFile }))
932
- );
933
- return (0, import_react7.createElement)("span", {
934
- ref: anchorRef,
935
- "data-file-native-anchor": true,
936
- "aria-hidden": true
937
- }, host !== null && host.isConnected ? (0, import_react_dom3.createPortal)(gallery, host) : null);
938
- }
939
- function OtherContext({ node }) {
940
- const [open, setOpen] = (0, import_react7.useState)(false);
941
- const data = node.data ?? {};
942
- const text = contentText(data.content);
943
- const summary = data.source?.summary || data.provenance?.label || "\u4E0A\u4E0B\u6587\u6CE8\u5165";
944
- const source = data.source?.plugin || data.provenance?.label || "";
924
+ }, [props.sessionId]);
925
+ if (hosted.length === 0) return null;
945
926
  return (0, import_react7.createElement)(
946
- "div",
947
- { className: "fr-ctx", "data-open": open || void 0 },
948
- (0, import_react7.createElement)(
949
- "button",
950
- {
951
- type: "button",
952
- className: "fr-ctx-head",
953
- onClick: () => setOpen((value) => !value)
954
- },
955
- (0, import_react7.createElement)("span", { className: "fr-ctx-title" }, "\u4E0A\u4E0B\u6587\u6CE8\u5165"),
956
- source ? (0, import_react7.createElement)("span", { className: "fr-ctx-src" }, source) : null,
957
- (0, import_react7.createElement)("span", { className: "fr-ctx-sum" }, summary)
958
- ),
959
- 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
+ ))
960
938
  );
961
939
  }
962
- function ContextNodeView(props) {
963
- ensureStyles();
964
- const node = props.node ?? {};
965
- const source = node.data?.source;
966
- if (source?.kind === "plugin" && source.plugin === "file-native") {
967
- const files = parseNoticeFiles(contentText(node.data?.content));
968
- if (files.length === 0) return null;
969
- return (0, import_react7.createElement)(AttachToUserBubble, { files, openFile: props.openFile });
970
- }
971
- return (0, import_react7.createElement)(OtherContext, { node });
972
- }
973
940
 
974
941
  // src/intercept.ts
975
942
  function looksLikeFileDrag(transfer) {
@@ -1061,7 +1028,7 @@ function installFileIntercept(handlers) {
1061
1028
 
1062
1029
  // src/client.ts
1063
1030
  var name = "file-native";
1064
- var inject = ["slots"];
1031
+ var inject = ["slots", "betterSidebar"];
1065
1032
  function RailSlot(props) {
1066
1033
  ensureStyles();
1067
1034
  const sessionId = resolveSessionId(props);
@@ -1076,27 +1043,64 @@ function RailSlot(props) {
1076
1043
  sessionId,
1077
1044
  dropLimits: props.dropLimits
1078
1045
  });
1079
- }, [sessionId, props.attachments, props.canAcceptDrop, props.onAddImages, props.onRemoveImage, props.dropLimits]);
1046
+ });
1080
1047
  (0, import_react8.useLayoutEffect)(() => () => clearLiveOwner(tokenRef.current), []);
1081
1048
  return null;
1082
1049
  }
1050
+ var chatCardOpener;
1083
1051
  function PickerSlot(props) {
1084
1052
  const sessionId = resolveSessionId(props);
1085
1053
  rememberSessionId(sessionId);
1086
- const phase = String(props.input?.phase ?? "");
1087
- const draft = String(props.input?.draft ?? "");
1088
- const imageCount = props.input?.imageIds?.length ?? 0;
1089
- 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"}`;
1058
+ (0, import_react8.useEffect)(() => {
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
+ };
1090
1074
  (0, import_react8.useEffect)(() => {
1091
- const sid = sessionId || currentSessionId();
1092
- const was = prev.current;
1093
- const entering = phase === "submitting" || phase === "claimed";
1094
- const leaving = (was.phase === "submitting" || was.phase === "claimed") && phase === "plain";
1095
- const sentPlain = was.phase !== "plain" && phase === "plain" && draft.trim() === "" && imageCount === 0;
1096
- if (sid && (entering || leaving || sentPlain)) archiveSent(sid);
1097
- prev.current = { phase, draft, imageCount };
1098
- }, [phase, draft, imageCount, sessionId]);
1099
- return (0, import_react8.createElement)(PaperclipButton, { sessionId });
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
+ );
1100
1104
  }
1101
1105
  function TailSlot(props) {
1102
1106
  return (0, import_react8.createElement)(UploadedTail, {
@@ -1121,28 +1125,51 @@ function installLiveIntercept() {
1121
1125
  }
1122
1126
  });
1123
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
+ })();
1124
1135
  function apply(ctx) {
1125
1136
  ctx.effect(() => installLiveIntercept(), "file-native: document intercept");
1126
- 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({
1127
1158
  name: "conversation.input.attachments",
1128
- priority: -1
1159
+ priority: base
1129
1160
  }, RailSlot));
1130
- ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
1161
+ safeInject("conversation.input.left", () => ctx.slots.register({
1131
1162
  name: "conversation.input.left",
1132
1163
  id: "file-native-picker",
1133
- order: 0
1164
+ order: 0,
1165
+ priority: base
1134
1166
  }, PickerSlot));
1135
- ctx.slots.inject("conversation.chat.node", () => ctx.slots.register({
1136
- name: "conversation.chat.node",
1137
- key: "context",
1138
- priority: -1
1139
- }, ContextNodeView));
1140
- ctx.slots.inject("conversation.chat.turnTail", () => ctx.slots.register({
1167
+ safeInject("conversation.chat.turnTail", () => ctx.slots.register({
1141
1168
  name: "conversation.chat.turnTail",
1142
- priority: -1,
1143
- select: selectTail
1169
+ select: selectTail,
1170
+ priority: base
1144
1171
  }, TailSlot));
1145
- ctx.logger?.info?.("[file-native] client loaded");
1172
+ console.info(`[file-native] client loaded (instance #${INSTANCE})`);
1146
1173
  }
1147
1174
 
1148
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.3",
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
+ }