dsh-files-native 0.1.3 → 0.2.1

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
@@ -11,12 +11,33 @@
11
11
 
12
12
  </div>
13
13
 
14
+ ## ⭐ 欢迎点星收藏
15
+
16
+ 如果附件轨帮到了你,欢迎到 [GitHub 仓库](https://github.com/meyaomiao/dsh-files-native) 点个 Star ⭐,让更多 DSH 用户看到它。问题与建议请提 Issue。
17
+
18
+ ## 📋 兼容性
19
+
20
+ | 插件版本 | 状态 | 对应 DSH |
21
+ |---|---|---|
22
+ | **0.2.x**(当前主线,含 0.2.1) | ✅ | **0.1.5-rc.1 / 0.1.5-rc.2**(及之后的 0.1.5 线) |
23
+ | 0.1.x | 🔧 维护态(仅修 bug) | DSH ≤ 0.1.2-rc.1(仍兼容 0.1.1-rc.2 / 0.1.2-alpha.4) |
24
+
25
+ ### 本次升级功能变化
26
+
27
+ - **官方已有的交给官方**:回复尾的「产物」卡不再由本插件绘制,改用 DSH 官方交付文件卡(0.1.5-rc.2 起排版/图标更紧)。用户气泡旁的附件卡仍由本插件画。
28
+ - **粘贴让路**:纯官方四类图(PNG / JPEG / WebP / GIF)交给官方草稿图;其它文件仍走本插件轨。
29
+ - **不依赖 better-sidebar**:模块 `inject` 只有 `slots`。有侧栏则气泡卡走预览,没有则新页签打开。
30
+ - 不要和 `dsh-file-fix` / `dsh-file-upload` / `dsh-paste-to-path` 叠装。
31
+
14
32
  运行时插件 id 仍是 `file-native`(上传路由 `/plugins/file-native/*`)。npm / GitHub 仓库名是 `dsh-files-native`。
15
33
 
16
34
  - **图片**(PNG / JPEG / WebP / GIF)走官方草稿图栏(64px 缩略图,点击灯箱),发送后进模型视觉上下文。
17
35
  - **其它文件**落到会话工作区 `.dsh-uploads/`,同一条轨上用 64px 高可读横条卡(扩展名角标 + 文件名 + 大小)。
36
+ - **附件只跟随「立即发送」的消息**:忙碌时回车 = 纯文字排队,文件留在附件轨等下次空闲发送。
37
+ - **气泡附件卡**:消息发出后,文件卡渲染在用户气泡上方(与图片同列右对齐),注入提示行自动隐藏。
38
+ - **点击打开**:点气泡附件卡——装了 [better-sidebar](https://github.com/用户/dsh-better-sidebar) 走侧栏预览;未装则新页签打开(文本/图片/PDF 页签内展示,二进制自动下载)。
18
39
  - 发送后文件卡贴在用户气泡栈(与官方图同一列),作业回复不会插在提问和文件中间。
19
- - 发送成功即清输入框待发轨。回复结尾只画「产物」chip。
40
+ - 发送成功即清输入框待发轨。**回复尾产物卡让给官方。**
20
41
 
21
42
  不要和 `dsh-file-fix` / `dsh-file-upload` / `dsh-paste-to-path` 叠装。
22
43
 
@@ -26,7 +47,7 @@
26
47
  |---|---|
27
48
  | ![空输入框](screenshots/01-empty.png) | ![待发混排](screenshots/02-rail.png) |
28
49
 
29
- | 拖入窗口任意位置 | 发送后:图与文件卡跟用户气泡,产物在回复尾 |
50
+ | 拖入窗口任意位置 | 发送后:图与文件卡跟用户气泡(产物走官方卡) |
30
51
  |---|---|
31
52
  | ![拖入遮罩](screenshots/03-overlay.png) | ![发送后](screenshots/04-sent.png) |
32
53
 
@@ -79,9 +100,4 @@ dsh plugin --profile web add .
79
100
  - 上传路由仅 loopback。
80
101
  - 刷新会丢掉「已上传未发送」的文件卡(磁盘文件仍在 `.dsh-uploads/`)。
81
102
 
82
- ## 兼容性
83
-
84
- - DeepSeek Harness `0.1.2-rc.1`(仍兼容 `0.1.1-rc.2` 与 `0.1.2-alpha.4`,web profile)
85
- - 不要和 `dsh-file-fix` / `dsh-file-upload` / `dsh-paste-to-path` 叠装
86
-
87
103
  改仓库前先读 [CONTRIBUTING.md](./CONTRIBUTING.md)(Issue → 分支 → Draft PR)。思考原则见 [AI-ISSUE-WORKFLOW.md](./AI-ISSUE-WORKFLOW.md)。插件硬约束见 [AGENTS.md](./AGENTS.md)。
package/lib/client.js CHANGED
@@ -28,7 +28,7 @@ __export(client_exports, {
28
28
  name: () => name
29
29
  });
30
30
  module.exports = __toCommonJS(client_exports);
31
- var import_react8 = require("react");
31
+ var import_react7 = require("react");
32
32
 
33
33
  // src/rail.tsx
34
34
  var import_react2 = require("react");
@@ -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,39 +70,30 @@ 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
- }
73
+ function planPaste(files) {
74
+ const nativeImages = [];
75
+ const cards = [];
76
+ for (const file of files) {
77
+ if (isNativeImage(file)) nativeImages.push(file);
78
+ else cards.push(file);
105
79
  }
106
- return parts.join("\n");
80
+ if (cards.length === 0 && nativeImages.length > 0) return { action: "yield" };
81
+ return { action: "take", cards, nativeImages };
82
+ }
83
+ function extractPasteText(raw, files) {
84
+ const text = raw.replace(/\uFFFC/g, "").trim();
85
+ if (text === "") return "";
86
+ const names = files.map((file) => file.name.replace(/^.*[/\\]/, "")).filter((name2) => name2.length >= 4);
87
+ if (names.length === 0) return text;
88
+ const tokens = text.split(/\s+/).filter(Boolean);
89
+ const referenced = (token) => names.some((name2) => token.toLowerCase().includes(name2.toLowerCase()));
90
+ const meaningful = tokens.filter((token) => !referenced(token));
91
+ return meaningful.length === 0 ? "" : text;
107
92
  }
108
93
 
109
94
  // src/store.ts
110
95
  var items = [];
111
96
  var sentBySession = /* @__PURE__ */ new Map();
112
- var sentByTurn = /* @__PURE__ */ new Map();
113
97
  var listeners = /* @__PURE__ */ new Set();
114
98
  var generation = 0;
115
99
  function emit() {
@@ -162,20 +146,6 @@ function archiveSent(sessionId) {
162
146
  if (files.length > 0) emit();
163
147
  return files;
164
148
  }
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
- function lastSent(sessionId) {
174
- return sentBySession.get(sessionId) ?? [];
175
- }
176
- function filesForTurn(sessionId, turn) {
177
- return sentByTurn.get(`${sessionId}:${turn}`) ?? lastSent(sessionId);
178
- }
179
149
 
180
150
  // src/styles.ts
181
151
  var FR_CSS = `
@@ -261,6 +231,9 @@ var FR_CSS = `
261
231
  .fr-ctx-body{box-sizing:border-box;background:var(--dsw-alias-markdown-code-block);width:calc(100% - 22px);max-height:141px;
262
232
  color:var(--dsw-alias-label-tertiary);font:400 11px/16px var(--ds-font-family-code, ui-monospace,monospace);
263
233
  border:none;border-radius:8px;margin:4px 0 0 22px;padding:10px 16px 12px 12px;overflow:auto;white-space:pre-wrap}
234
+ [data-file-native-queue]{flex:none;display:inline-flex;align-items:center;gap:2px;height:20px;padding:0 7px;
235
+ border-radius:999px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-interactive-bg-hover);
236
+ font:500 11px/20px Inter, var(--dsw-font-family, system-ui);white-space:nowrap;pointer-events:none}
264
237
  `;
265
238
  var injected = false;
266
239
  function ensureStyles() {
@@ -484,8 +457,7 @@ function FileRail(props) {
484
457
  const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
485
458
  setEdges((prev) => prev.left === left && prev.right === right ? prev : { left, right });
486
459
  };
487
- const visibleFiles = files.filter((item) => item.sessionId === sessionId);
488
- const itemCount = props.attachments.length + visibleFiles.length;
460
+ const itemCount = props.attachments.length + files.length;
489
461
  (0, import_react2.useLayoutEffect)(() => {
490
462
  const grew = countRef.current !== 0 && itemCount > countRef.current;
491
463
  countRef.current = itemCount;
@@ -521,7 +493,7 @@ function FileRail(props) {
521
493
  el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: "smooth" });
522
494
  };
523
495
  const hasImages = props.attachments.length > 0;
524
- const hasFiles = visibleFiles.length > 0;
496
+ const hasFiles = files.length > 0;
525
497
  if (!hasImages && !hasFiles) return null;
526
498
  return (0, import_react2.createElement)(
527
499
  "div",
@@ -539,7 +511,7 @@ function FileRail(props) {
539
511
  onRemove: () => props.onRemoveImage(item.id),
540
512
  onOpen: () => setPreview(item)
541
513
  })),
542
- ...visibleFiles.map((item) => (0, import_react2.createElement)(FileTile, { key: item.id, item, sessionId }))
514
+ ...files.map((item) => (0, import_react2.createElement)(FileTile, { key: item.id, item, sessionId }))
543
515
  ),
544
516
  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
517
  ),
@@ -629,7 +601,7 @@ function addImages(sessionId, files) {
629
601
  }
630
602
  const key = sid || owner?.sessionId || lastSessionId;
631
603
  const prev = queuedImages.get(key) ?? [];
632
- queuedImages.set(key, prev.concat([...files]));
604
+ queuedImages.set(key, prev.concat(files));
633
605
  }
634
606
  function setDragDepth(depth) {
635
607
  const next = Math.max(0, depth);
@@ -717,6 +689,86 @@ function ComposerRail(props) {
717
689
  }, host !== null && host.isConnected ? (0, import_react_dom2.createPortal)(rail, host) : null);
718
690
  }
719
691
 
692
+ // src/vision.ts
693
+ function currentModelLabel() {
694
+ const buttons = document.querySelectorAll("button[aria-label]");
695
+ for (const button of buttons) {
696
+ const label = button.getAttribute("aria-label") ?? "";
697
+ if (/选择模型|select model|current model/i.test(label)) return label;
698
+ }
699
+ return "";
700
+ }
701
+ function insertComposerText(text) {
702
+ const active = document.activeElement;
703
+ const el = active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement ? active : document.querySelector("[data-composer-card] textarea");
704
+ if (!(el instanceof HTMLTextAreaElement) && !(el instanceof HTMLInputElement)) return;
705
+ el.focus();
706
+ let inserted = false;
707
+ try {
708
+ inserted = document.execCommand("insertText", false, text);
709
+ } catch {
710
+ inserted = false;
711
+ }
712
+ if (inserted) return;
713
+ const proto = el instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
714
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
715
+ setter?.call(el, `${el.value}${text}`);
716
+ el.dispatchEvent(new Event("input", { bubbles: true }));
717
+ }
718
+ function pasteAccompanyingText(text) {
719
+ insertComposerText(text);
720
+ }
721
+ var modlensRouteAvailable = true;
722
+ async function probeModlensTakeover() {
723
+ if (!modlensRouteAvailable) return false;
724
+ const label = currentModelLabel();
725
+ try {
726
+ const res = await fetch(`/modlens/paste?model=${encodeURIComponent(label)}`, {
727
+ signal: AbortSignal.timeout(2e3)
728
+ });
729
+ if (res.status === 404) {
730
+ modlensRouteAvailable = false;
731
+ return false;
732
+ }
733
+ if (!res.ok) return false;
734
+ const body = await res.json();
735
+ return body.takeover === true;
736
+ } catch {
737
+ return false;
738
+ }
739
+ }
740
+ async function uploadModlensPaste(file) {
741
+ const res = await fetch("/modlens/paste", { method: "POST", body: await file.arrayBuffer() });
742
+ if (!res.ok) return void 0;
743
+ const body = await res.json();
744
+ return typeof body.path === "string" && body.path !== "" ? body.path : void 0;
745
+ }
746
+ async function deliverImages(sessionId, files) {
747
+ if (files.length === 0) return;
748
+ const takeover = await probeModlensTakeover();
749
+ if (!takeover) {
750
+ addImages(sessionId, files);
751
+ return;
752
+ }
753
+ const paths = [];
754
+ const failed = [];
755
+ for (const file of files) {
756
+ try {
757
+ const path = await uploadModlensPaste(file);
758
+ if (path !== void 0) paths.push(path);
759
+ else failed.push(file);
760
+ } catch {
761
+ failed.push(file);
762
+ }
763
+ }
764
+ if (paths.length === 0) {
765
+ addImages(sessionId, files);
766
+ return;
767
+ }
768
+ if (failed.length > 0) addImages(sessionId, failed);
769
+ insertComposerText(`${paths.join(" ")} `);
770
+ }
771
+
720
772
  // src/picker.tsx
721
773
  function PaperclipButton(props) {
722
774
  ensureStyles();
@@ -730,7 +782,9 @@ function PaperclipButton(props) {
730
782
  event.currentTarget.value = "";
731
783
  const sid = String(props.sessionId || currentSessionId());
732
784
  if (files.length === 0) return;
733
- intakeFiles(sid, files, (images) => addImages(sid, images));
785
+ intakeFiles(sid, files, (images) => {
786
+ void deliverImages(sid, images);
787
+ });
734
788
  };
735
789
  return (0, import_react5.createElement)(
736
790
  "span",
@@ -754,106 +808,28 @@ function PaperclipButton(props) {
754
808
  );
755
809
  }
756
810
 
757
- // src/tail.tsx
758
- var import_react6 = require("react");
759
- function basename(path) {
760
- const at = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
761
- return at === -1 ? path : path.slice(at + 1);
762
- }
763
- function ChipRow({ label, items: items2, open }) {
764
- if (items2.length === 0) return null;
765
- const shown = items2.slice(0, 6);
766
- const hidden = items2.length - shown.length;
767
- return (0, import_react6.createElement)(
768
- "div",
769
- { className: "fr-tail" },
770
- (0, import_react6.createElement)("span", { className: "fr-tail-label" }, label),
771
- (0, import_react6.createElement)(
772
- "div",
773
- { className: "fr-tail-row" },
774
- ...shown.map((item) => (0, import_react6.createElement)("button", {
775
- key: item.path,
776
- type: "button",
777
- className: "fr-chip",
778
- title: item.path,
779
- onClick: () => open(item.path)
780
- }, item.name)),
781
- hidden > 0 ? (0, import_react6.createElement)("span", { className: "fr-more" }, `+ ${hidden} \u4E2A\u6587\u4EF6`) : null
782
- )
783
- );
784
- }
785
- function UploadedTail(props) {
786
- ensureStyles();
787
- const sessionId = String(props.sessionId ?? "");
788
- const [uploaded, setUploaded] = (0, import_react6.useState)(
789
- props.matched?.uploaded ?? (props.turn !== void 0 ? filesForTurn(sessionId, props.turn) : lastSent(sessionId))
790
- );
791
- (0, import_react6.useEffect)(() => {
792
- if (props.matched?.uploaded) {
793
- setUploaded(props.matched.uploaded);
794
- return;
795
- }
796
- let cancelled = false;
797
- void fetch(`/plugins/file-native/last?sessionId=${encodeURIComponent(sessionId)}`).then((res) => res.json()).then((body) => {
798
- if (!cancelled && Array.isArray(body.files) && body.files.length > 0) setUploaded(body.files);
799
- }).catch(() => {
800
- });
801
- return () => {
802
- cancelled = true;
803
- };
804
- }, [sessionId, props.matched, props.turn]);
805
- const produced = props.matched?.produced ?? [];
806
- if (uploaded.length === 0 && produced.length === 0) return null;
807
- const open = props.openFile ?? ((path) => {
808
- window.open(path, "_blank");
809
- });
810
- return (0, import_react6.createElement)(
811
- "div",
812
- { "data-file-native-tail": true },
813
- (0, import_react6.createElement)(ChipRow, {
814
- label: "\u9644\u4EF6",
815
- items: uploaded.map((file) => ({ name: file.name, path: file.relPath })),
816
- open
817
- }),
818
- (0, import_react6.createElement)(ChipRow, {
819
- label: "\u4EA7\u7269",
820
- items: produced.map((path) => ({ name: basename(path), path })),
821
- open
822
- })
823
- );
824
- }
825
- function producedPathsOf(owner2) {
826
- const data = owner2.turn?.data?.get?.("deliverables");
827
- const seq = owner2.seq ?? Number.POSITIVE_INFINITY;
828
- if (!data?.produced) return [];
829
- const paths = [];
830
- const seen = /* @__PURE__ */ new Set();
831
- for (const item of data.produced) {
832
- if (item.seq > seq || seen.has(item.path)) continue;
833
- seen.add(item.path);
834
- paths.push(item.path);
835
- }
836
- return paths;
837
- }
838
-
839
811
  // src/context.tsx
840
- var import_react7 = require("react");
812
+ var import_react6 = require("react");
841
813
  var import_react_dom3 = require("react-dom");
814
+ function isFileNoticeRow(el) {
815
+ const text = el.textContent ?? "";
816
+ return text.includes("\u{1F4CE} \u9644\u4EF6") && text.includes("\u4E2A\u6587\u4EF6");
817
+ }
842
818
  function FileCard({ file, onOpen }) {
843
- return (0, import_react7.createElement)(
819
+ return (0, import_react6.createElement)(
844
820
  "button",
845
821
  {
846
822
  type: "button",
847
823
  className: "fr-msg-card",
848
824
  title: file.relPath,
849
- onClick: () => onOpen?.(file.relPath)
825
+ onClick: () => onOpen?.(file)
850
826
  },
851
- (0, import_react7.createElement)("span", { className: "fr-ext" }, extOf(file.name)),
852
- (0, import_react7.createElement)(
827
+ (0, import_react6.createElement)("span", { className: "fr-ext" }, extOf(file.name)),
828
+ (0, import_react6.createElement)(
853
829
  "span",
854
830
  { className: "fr-copy" },
855
- (0, import_react7.createElement)("span", { className: "fr-name" }, file.name),
856
- (0, import_react7.createElement)("span", { className: "fr-meta" }, formatSize(file.size))
831
+ (0, import_react6.createElement)("span", { className: "fr-name" }, file.name),
832
+ file.size > 0 ? (0, import_react6.createElement)("span", { className: "fr-meta" }, formatSize(file.size)) : null
857
833
  )
858
834
  );
859
835
  }
@@ -861,15 +837,11 @@ function userStackOf(el) {
861
837
  if (!(el instanceof HTMLElement)) return null;
862
838
  const kind = el.getAttribute("data-chat-flow-kind");
863
839
  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;
840
+ return el.querySelector('[class*="userStack"]');
841
+ }
842
+ function findUserStack(notice) {
843
+ let next = notice.nextElementSibling;
844
+ let prev = notice.previousElementSibling;
873
845
  for (let hop = 0; hop < 16; hop += 1) {
874
846
  const stack = userStackOf(next) ?? userStackOf(prev);
875
847
  if (stack !== null) return stack;
@@ -878,98 +850,106 @@ function findUserStack(anchor) {
878
850
  }
879
851
  return null;
880
852
  }
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]");
853
+ function placeHostInStack(stack) {
854
+ let el = stack.querySelector(":scope > [data-file-native-msg]");
888
855
  if (el === null) {
889
856
  el = document.createElement("div");
890
857
  el.dataset.fileNativeMsg = "";
891
858
  el.className = "fr-msg-row";
859
+ stack.insertBefore(el, stack.firstChild);
892
860
  }
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);
861
+ return el;
862
+ }
863
+ function placeHostInRow(notice) {
864
+ let el = notice.querySelector(":scope > [data-file-native-msg]");
865
+ if (el === null) {
866
+ el = document.createElement("div");
867
+ el.dataset.fileNativeMsg = "";
868
+ 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";
869
+ notice.appendChild(el);
898
870
  }
899
871
  return el;
900
872
  }
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]);
909
- (0, import_react7.useEffect)(() => {
873
+ function MessageCards(props) {
874
+ ensureStyles();
875
+ const [hosted, setHosted] = (0, import_react6.useState)([]);
876
+ const sessionKey = (0, import_react6.useRef)("");
877
+ const lastCount = (0, import_react6.useRef)(-1);
878
+ const cachedFiles = (0, import_react6.useRef)(null);
879
+ (0, import_react6.useEffect)(() => {
880
+ if (sessionKey.current !== (props.sessionId ?? "")) {
881
+ sessionKey.current = props.sessionId ?? "";
882
+ lastCount.current = -1;
883
+ cachedFiles.current = null;
884
+ }
910
885
  let cancelled = false;
911
- let tries = 0;
912
886
  const tick = () => {
913
887
  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);
888
+ const run = async () => {
889
+ const rows = [...document.querySelectorAll('[data-chat-flow-kind="context"]')].filter((el) => isFileNoticeRow(el));
890
+ if (rows.length === 0) {
891
+ if (lastCount.current !== 0) {
892
+ lastCount.current = 0;
893
+ setHosted([]);
894
+ }
895
+ return;
896
+ }
897
+ if (rows.length !== lastCount.current) {
898
+ const sid = props.sessionId ?? "";
899
+ if (sid !== "") {
900
+ try {
901
+ const res = await fetch(`/plugins/file-native/last?sessionId=${encodeURIComponent(sid)}`);
902
+ const body = await res.json();
903
+ cachedFiles.current = Array.isArray(body.files) ? body.files : [];
904
+ } catch {
905
+ cachedFiles.current = cachedFiles.current ?? [];
906
+ }
907
+ }
908
+ lastCount.current = rows.length;
909
+ }
910
+ const files = cachedFiles.current ?? [];
911
+ if (files.length === 0) return;
912
+ const latest = rows[rows.length - 1];
913
+ const stack = findUserStack(latest);
914
+ const host = stack !== null ? placeHostInStack(stack) : placeHostInRow(latest);
915
+ if (stack !== null) {
916
+ if (latest.querySelector("[data-file-native-anchor]") === null) {
917
+ const anchor = document.createElement("span");
918
+ anchor.dataset.fileNativeAnchor = "";
919
+ anchor.setAttribute("aria-hidden", "true");
920
+ latest.appendChild(anchor);
921
+ }
922
+ }
923
+ setHosted((prev) => {
924
+ if (prev.length === 1 && prev[0].host === host) return prev;
925
+ return [{ host, files }];
926
+ });
927
+ };
928
+ void run().catch(() => {
929
+ }).then(() => {
930
+ if (!cancelled) window.setTimeout(tick, 300);
931
+ });
922
932
  };
923
933
  tick();
924
934
  return () => {
925
935
  cancelled = true;
926
936
  };
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 || "";
945
- 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
937
+ }, [props.sessionId]);
938
+ if (hosted.length === 0) return null;
939
+ return (0, import_react6.createElement)(
940
+ "span",
941
+ { "aria-hidden": true },
942
+ ...hosted.map((item, i) => (0, import_react_dom3.createPortal)(
943
+ (0, import_react6.createElement)(
944
+ "div",
945
+ { className: "fr-msg-row-inner" },
946
+ ...item.files.map((file) => (0, import_react6.createElement)(FileCard, { key: file.relPath, file, onOpen: props.onOpen }))
947
+ ),
948
+ item.host,
949
+ `file-native-cards-${i}`
950
+ ))
960
951
  );
961
952
  }
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
953
 
974
954
  // src/intercept.ts
975
955
  function looksLikeFileDrag(transfer) {
@@ -1039,9 +1019,16 @@ function installFileIntercept(handlers) {
1039
1019
  const fromItems = Array.from(event.clipboardData?.items ?? []).filter((item) => item.kind === "file").map((item) => item.getAsFile()).filter((file) => file !== null);
1040
1020
  const files = fromList.length > 0 ? fromList : fromItems;
1041
1021
  if (files.length === 0) return;
1022
+ const plan = planPaste(files);
1023
+ if (plan.action === "yield") return;
1042
1024
  event.preventDefault();
1043
- event.stopPropagation();
1044
- handlers.onFiles(files);
1025
+ event.stopImmediatePropagation();
1026
+ if (plan.cards.length > 0) handlers.onFiles(plan.cards);
1027
+ if (plan.nativeImages.length > 0) handlers.onImages?.(plan.nativeImages);
1028
+ if (handlers.onText !== void 0) {
1029
+ const kept = extractPasteText(event.clipboardData?.getData("text/plain") ?? "", files);
1030
+ if (kept !== "") handlers.onText(kept);
1031
+ }
1045
1032
  };
1046
1033
  document.addEventListener("dragenter", onDragEnter, true);
1047
1034
  document.addEventListener("dragover", onDragOver, true);
@@ -1066,8 +1053,8 @@ function RailSlot(props) {
1066
1053
  ensureStyles();
1067
1054
  const sessionId = resolveSessionId(props);
1068
1055
  rememberSessionId(sessionId);
1069
- const tokenRef = (0, import_react8.useRef)(0);
1070
- (0, import_react8.useLayoutEffect)(() => {
1056
+ const tokenRef = (0, import_react7.useRef)(0);
1057
+ (0, import_react7.useLayoutEffect)(() => {
1071
1058
  tokenRef.current = setLiveOwner({
1072
1059
  attachments: props.attachments,
1073
1060
  canAcceptDrop: props.canAcceptDrop,
@@ -1076,40 +1063,64 @@ function RailSlot(props) {
1076
1063
  sessionId,
1077
1064
  dropLimits: props.dropLimits
1078
1065
  });
1079
- }, [sessionId, props.attachments, props.canAcceptDrop, props.onAddImages, props.onRemoveImage, props.dropLimits]);
1080
- (0, import_react8.useLayoutEffect)(() => () => clearLiveOwner(tokenRef.current), []);
1066
+ });
1067
+ (0, import_react7.useLayoutEffect)(() => () => clearLiveOwner(tokenRef.current), []);
1081
1068
  return null;
1082
1069
  }
1070
+ var chatCardOpener;
1083
1071
  function PickerSlot(props) {
1084
1072
  const sessionId = resolveSessionId(props);
1085
1073
  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 });
1090
- (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 });
1100
- }
1101
- function TailSlot(props) {
1102
- return (0, import_react8.createElement)(UploadedTail, {
1103
- sessionId: props.sessionId,
1104
- openFile: props.openFile,
1105
- matched: props.matched ?? null,
1106
- turn: props.turn?.turn
1107
- });
1108
- }
1109
- function selectTail(owner2) {
1110
- const produced = producedPathsOf(owner2);
1111
- if (produced.length === 0) return null;
1112
- return { uploaded: [], produced };
1074
+ const running = Boolean(props.useSession?.((state) => state.running));
1075
+ const inputActions = props.inputActions;
1076
+ const submitAction = inputActions?.submit;
1077
+ const kitSig = `inputActions=${props.inputActions ? "y" : "n"} useSession=${props.useSession ? "y" : "n"}`;
1078
+ (0, import_react7.useEffect)(() => {
1079
+ console.info(`[file-native] kit: ${kitSig}`);
1080
+ }, [kitSig]);
1081
+ const submitWithFiles = (ev, sid) => {
1082
+ const submitFn = inputActions?.submit;
1083
+ archiveSent(sid);
1084
+ if (submitFn === void 0) {
1085
+ void fetch(`/plugins/file-native/arm?sessionId=${encodeURIComponent(sid)}`, { method: "POST" }).catch(() => {
1086
+ });
1087
+ return;
1088
+ }
1089
+ ev.preventDefault();
1090
+ ev.stopPropagation();
1091
+ void fetch(`/plugins/file-native/arm?sessionId=${encodeURIComponent(sid)}`, { method: "POST" }).catch(() => {
1092
+ }).then(() => submitFn());
1093
+ };
1094
+ (0, import_react7.useEffect)(() => {
1095
+ const onKeyDown = (ev) => {
1096
+ if (ev.key !== "Enter" || ev.shiftKey || ev.isComposing || ev.altKey || ev.ctrlKey || ev.metaKey) return;
1097
+ if (running) return;
1098
+ const sid = sessionId || currentSessionId();
1099
+ if (sid === "" || doneFiles(sid).length === 0) return;
1100
+ submitWithFiles(ev, sid);
1101
+ };
1102
+ document.addEventListener("keydown", onKeyDown, true);
1103
+ return () => document.removeEventListener("keydown", onKeyDown, true);
1104
+ }, [running, inputActions, sessionId]);
1105
+ (0, import_react7.useEffect)(() => {
1106
+ const onClick = (ev) => {
1107
+ if (running) return;
1108
+ const button = ev.target instanceof Element ? ev.target.closest("button") : null;
1109
+ if (button === null) return;
1110
+ if (!/[a-zA-Z0-9_-]*primary/.test(button.className)) return;
1111
+ const sid = sessionId || currentSessionId();
1112
+ if (sid === "" || doneFiles(sid).length === 0) return;
1113
+ submitWithFiles(ev, sid);
1114
+ };
1115
+ document.addEventListener("click", onClick, true);
1116
+ return () => document.removeEventListener("click", onClick, true);
1117
+ }, [running, inputActions, sessionId]);
1118
+ return (0, import_react7.createElement)(
1119
+ "span",
1120
+ null,
1121
+ (0, import_react7.createElement)(PaperclipButton, { sessionId }),
1122
+ (0, import_react7.createElement)(MessageCards, { sessionId, onOpen: (file) => chatCardOpener?.(sessionId || currentSessionId(), file.relPath, file.name) })
1123
+ );
1113
1124
  }
1114
1125
  function installLiveIntercept() {
1115
1126
  return installFileIntercept({
@@ -1117,32 +1128,62 @@ function installLiveIntercept() {
1117
1128
  onDepth: (depth) => setDragDepth(depth),
1118
1129
  onFiles: (files) => {
1119
1130
  const sessionId = currentSessionId();
1120
- intakeFiles(sessionId, files, (images) => addImages(sessionId, images));
1121
- }
1131
+ intakeFiles(sessionId, files, (images) => {
1132
+ void deliverImages(sessionId, images);
1133
+ });
1134
+ },
1135
+ onImages: (files) => {
1136
+ const sessionId = currentSessionId();
1137
+ void deliverImages(sessionId, files);
1138
+ },
1139
+ onText: (text) => pasteAccompanyingText(text)
1122
1140
  });
1123
1141
  }
1142
+ var INSTANCE = (() => {
1143
+ const w = globalThis;
1144
+ w.__fileNativeInstances = (w.__fileNativeInstances ?? 0) + 1;
1145
+ const n = w.__fileNativeInstances;
1146
+ console.warn(`[file-native] apply #${n}`);
1147
+ return n;
1148
+ })();
1124
1149
  function apply(ctx) {
1125
1150
  ctx.effect(() => installLiveIntercept(), "file-native: document intercept");
1126
- ctx.slots.inject("conversation.input.attachments", () => ctx.slots.register({
1151
+ let sidebar;
1152
+ try {
1153
+ sidebar = ctx.betterSidebar;
1154
+ } catch {
1155
+ sidebar = void 0;
1156
+ }
1157
+ if (sidebar !== void 0 && sidebar.features.includes("openFile")) {
1158
+ chatCardOpener = (sid, relPath, name2) => sidebar.openFile({ sessionId: sid }, relPath, name2);
1159
+ } else {
1160
+ chatCardOpener = (sid, relPath) => {
1161
+ window.open(`/plugins/file-native/file?sessionId=${encodeURIComponent(sid)}&path=${encodeURIComponent(relPath)}`, "_blank");
1162
+ };
1163
+ }
1164
+ const base = -(1e3 + INSTANCE * 10);
1165
+ const safeInject = (slot, registerFn) => {
1166
+ ctx.slots.inject(slot, () => {
1167
+ try {
1168
+ return registerFn();
1169
+ } catch (error) {
1170
+ console.warn(`[file-native] skip ${slot} registration:`, error);
1171
+ return () => {
1172
+ };
1173
+ }
1174
+ });
1175
+ };
1176
+ safeInject("conversation.input.attachments", () => ctx.slots.register({
1127
1177
  name: "conversation.input.attachments",
1128
- priority: -1
1178
+ priority: base
1129
1179
  }, RailSlot));
1130
- ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
1180
+ safeInject("conversation.input.left", () => ctx.slots.register({
1131
1181
  name: "conversation.input.left",
1132
1182
  id: "file-native-picker",
1133
- order: 0
1183
+ order: 0,
1184
+ priority: base
1134
1185
  }, 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({
1141
- name: "conversation.chat.turnTail",
1142
- priority: -1,
1143
- select: selectTail
1144
- }, TailSlot));
1145
- ctx.logger?.info?.("[file-native] client loaded");
1186
+ console.info(`[file-native] client loaded (instance #${INSTANCE})`);
1146
1187
  }
1147
1188
 
1148
1189
  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.1",
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
+ }