dsh-workbuddy-files 0.1.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/lib/client.js ADDED
@@ -0,0 +1,984 @@
1
+ //#region src/client/lib/icons.ts
2
+ /** 文件类型 → 图标/颜色映射(WorkBuddy 风格的类型标识) */
3
+ const ICONS = {
4
+ pdf: "📕",
5
+ doc: "📘",
6
+ docx: "📘",
7
+ xls: "📊",
8
+ xlsx: "📊",
9
+ csv: "📊",
10
+ ppt: "📙",
11
+ pptx: "📙",
12
+ png: "🖼️",
13
+ jpg: "🖼️",
14
+ jpeg: "🖼️",
15
+ gif: "🖼️",
16
+ webp: "🖼️",
17
+ svg: "🖼️",
18
+ bmp: "🖼️",
19
+ zip: "🗜️",
20
+ "7z": "🗜️",
21
+ rar: "🗜️",
22
+ tar: "🗜️",
23
+ gz: "🗜️",
24
+ mp3: "🎵",
25
+ wav: "🎵",
26
+ flac: "🎵",
27
+ mp4: "🎬",
28
+ mov: "🎬",
29
+ mkv: "🎬",
30
+ md: "📝",
31
+ txt: "📄",
32
+ log: "📄",
33
+ js: "💻",
34
+ ts: "💻",
35
+ jsx: "💻",
36
+ tsx: "💻",
37
+ py: "💻",
38
+ go: "💻",
39
+ rs: "💻",
40
+ java: "💻",
41
+ c: "💻",
42
+ h: "💻",
43
+ cpp: "💻",
44
+ cs: "💻",
45
+ json: "💻",
46
+ yaml: "💻",
47
+ yml: "💻",
48
+ toml: "💻",
49
+ sh: "💻",
50
+ ps1: "💻",
51
+ bat: "💻",
52
+ css: "💻",
53
+ html: "💻",
54
+ vue: "💻",
55
+ sql: "💻"
56
+ };
57
+ function iconFor(name) {
58
+ const dot = String(name).lastIndexOf(".");
59
+ const ext = dot >= 0 ? String(name).slice(dot + 1).toLowerCase() : "";
60
+ return ICONS[ext] ?? "📄";
61
+ }
62
+ function formatSize(n) {
63
+ if (n === null || n === void 0 || !Number.isFinite(n)) return "";
64
+ if (n < 1024) return n + " B";
65
+ if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
66
+ if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
67
+ return (n / 1073741824).toFixed(2) + " GB";
68
+ }
69
+ //#endregion
70
+ //#region src/client/lib/transfer.ts
71
+ /** 引用文本格式:无空格直接 @path;含空格/引号用 @"path";目录保留尾斜杠 */
72
+ function mentionFor(path, isDir) {
73
+ let p = String(path);
74
+ if (isDir) p = p.replace(/[\\/]+$/, "") + "/";
75
+ if (/\s|"/.test(p)) return "@\"" + p + "\"";
76
+ return "@" + p;
77
+ }
78
+ /** 从用户消息文本中提取 @ 文件引用(消息发送后的序列化形式) */
79
+ function extractRefs(content) {
80
+ const out = [];
81
+ for (const b of content) if (b !== null && b !== void 0 && b.type === "text" && typeof b.text === "string") {
82
+ const re = /@"([^"]+)"|@([^\s"@]+)/g;
83
+ let m;
84
+ while ((m = re.exec(b.text)) !== null) {
85
+ const p = (m[1] !== void 0 ? m[1] : m[2]).trim();
86
+ if (p !== "" && !out.includes(p)) out.push(p);
87
+ }
88
+ }
89
+ return out.slice(0, 40);
90
+ }
91
+ /**
92
+ * 后台上传任务队列:逐个落盘(目录任务先遍历),不阻塞输入。
93
+ * 交付物版本走 webServer 二进制路由(/workbuddy-drops),无 base64、无 JSON 体积上限。
94
+ * @returns 成功数量与失败清单(文件名 + 原因)
95
+ */
96
+ async function runUploadJobs(jobs, batch, maxFileBytes = 268435456) {
97
+ let ok = 0;
98
+ const failed = [];
99
+ for (const j of jobs) {
100
+ const files = j.kind === "dir" ? await walkEntry(j.entry, "") : [{
101
+ rel: j.rel,
102
+ name: j.name,
103
+ size: j.file.size,
104
+ file: j.file
105
+ }];
106
+ for (const f of files) {
107
+ if (f.file.size > maxFileBytes) {
108
+ failed.push(f.name + "(超过大小上限)");
109
+ continue;
110
+ }
111
+ const url = "/workbuddy-drops/save?batch=" + encodeURIComponent(batch) + "&rel=" + encodeURIComponent(f.rel);
112
+ const res = await fetch(url, {
113
+ method: "POST",
114
+ body: f.file
115
+ });
116
+ if (res.ok) {
117
+ const body = await res.json();
118
+ if (body.ok === true) {
119
+ ok += 1;
120
+ continue;
121
+ }
122
+ failed.push(f.name + "(" + (body.error ?? "写入失败") + ")");
123
+ } else failed.push(f.name + "(HTTP " + res.status + ")");
124
+ }
125
+ }
126
+ return {
127
+ ok,
128
+ failed
129
+ };
130
+ }
131
+ /** 缓存根目录(~/.dsh-drops) */
132
+ async function dropsHome() {
133
+ try {
134
+ const body = await (await fetch("/workbuddy-drops/home")).json();
135
+ if (body.ok === true && typeof body.root === "string") return body.root;
136
+ return null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+ function walkEntry(entry, rel) {
142
+ return new Promise((resolve2) => {
143
+ if (entry.isFile) {
144
+ entry.file((f) => resolve2([{
145
+ rel: rel === "" ? entry.name : rel + "/" + entry.name,
146
+ name: entry.name,
147
+ size: f.size,
148
+ file: f
149
+ }]), () => resolve2([]));
150
+ return;
151
+ }
152
+ if (entry.isDirectory) {
153
+ const reader = entry.createReader();
154
+ const found = [];
155
+ const readBatch = () => reader.readEntries((ents) => {
156
+ if (ents.length === 0) {
157
+ Promise.all(found.map((e) => walkEntry(e, rel === "" ? entry.name : rel + "/" + entry.name))).then((rs) => resolve2(rs.flat())).catch(() => resolve2([]));
158
+ return;
159
+ }
160
+ found.push(...ents);
161
+ readBatch();
162
+ }, () => resolve2([]));
163
+ readBatch();
164
+ return;
165
+ }
166
+ resolve2([]);
167
+ });
168
+ }
169
+ async function dropsList(query) {
170
+ try {
171
+ return (await (await fetch("/workbuddy-drops/list?query=" + encodeURIComponent(query))).json()).items ?? [];
172
+ } catch {
173
+ return [];
174
+ }
175
+ }
176
+ async function dropsStat(path) {
177
+ try {
178
+ return await (await fetch("/workbuddy-drops/stat?path=" + encodeURIComponent(path))).json();
179
+ } catch {
180
+ return {
181
+ ok: false,
182
+ path
183
+ };
184
+ }
185
+ }
186
+ //#endregion
187
+ //#region src/client/at-source.ts
188
+ /**
189
+ * @ 触发源「workbuddy」:输入 @ 时在菜单中追加「文件缓存」分组 ——
190
+ * ~/.dsh-drops 中拖拽/粘贴/选择落地的文件与目录树,支持搜索。
191
+ * 选中后由输入管线的 onPick → { insert } 在触发词位置铸造原生气泡。
192
+ *
193
+ * 工作区文件/文件夹的 @ 检索由 DSH 官方 ui-reference 源提供(文件与文件夹 +
194
+ * Session 分组),本插件与之共存,无需重复实现。
195
+ *
196
+ * 所有引用在拖入时已完成落地(真实绝对路径),因此 codec 序列化是恒等函数,
197
+ * 发送消息不可能因文件未落地而失败。
198
+ */
199
+ function createAtSource() {
200
+ return {
201
+ trigger: "@",
202
+ name: "workbuddy",
203
+ order: 5,
204
+ showGroupTitle: true,
205
+ async candidates(_session, req) {
206
+ const items = await dropsList(req && req.query || "");
207
+ const out = [];
208
+ for (const it of items) {
209
+ const isDir = it.type === "directory";
210
+ out.push({
211
+ name: (isDir ? "📁 " : iconFor(it.name) + " ") + it.name + (isDir ? "/" : ""),
212
+ description: it.path,
213
+ section: "文件缓存 · ~/.dsh-drops",
214
+ value: JSON.stringify({
215
+ kind: isDir ? "folder" : "file",
216
+ name: it.name,
217
+ path: it.path
218
+ })
219
+ });
220
+ }
221
+ return out.slice(0, 60);
222
+ },
223
+ onPick(pick) {
224
+ let v = null;
225
+ try {
226
+ v = JSON.parse(pick.candidate.value || "null");
227
+ } catch {
228
+ return;
229
+ }
230
+ if (v === null || typeof v !== "object" || typeof v.path !== "string") return void 0;
231
+ const mention = mentionFor(v.path, v.kind === "folder");
232
+ return { insert: {
233
+ source: "workbuddy",
234
+ ref: mention,
235
+ label: v.name ?? "",
236
+ appearance: v.kind === "folder" ? "folder" : "file",
237
+ clipboardText: mention
238
+ } };
239
+ },
240
+ codec: {
241
+ clipboardText: (ref) => ref,
242
+ serialize: (ref) => Promise.resolve(ref)
243
+ }
244
+ };
245
+ }
246
+ //#endregion
247
+ //#region src/client/components/file-cards.tsx
248
+ /**
249
+ * 对话区文件卡片(挂在 conversation.chat.turnTail 链式槽):
250
+ * 用户消息发送后,该轮次消息中引用的文件以「类型图标 + 文件名 + 大小/文件夹」卡片
251
+ * 渲染在轮次尾部;点击卡片经 owner 的 openFile 打开文件。
252
+ * selector 只匹配含文件引用的轮次(见 definitions.ts),不抢占其他链条目。
253
+ */
254
+ function createFileCardsComponent(React) {
255
+ function FileCard(props) {
256
+ const { path, openFile } = props;
257
+ const [meta, setMeta] = React.useState(null);
258
+ React.useEffect(() => {
259
+ let live = true;
260
+ dropsStat(path).then((r) => {
261
+ if (live) setMeta(r);
262
+ }, () => {
263
+ if (live) setMeta({
264
+ ok: false,
265
+ path
266
+ });
267
+ });
268
+ return () => {
269
+ live = false;
270
+ };
271
+ }, [path]);
272
+ const name = String(path).split(/[\\/]/).pop() || path;
273
+ const good = meta !== null && meta !== void 0 && meta.ok === true && meta.exists === true;
274
+ const isDir = good && meta.type === "directory";
275
+ const icon = isDir ? "📁" : iconFor(name);
276
+ const sub = meta === null ? "…" : !good ? "不可用" : isDir ? "文件夹" : formatSize(meta.size);
277
+ return React.createElement("button", {
278
+ type: "button",
279
+ className: "wbd-card",
280
+ title: path,
281
+ onClick: () => {
282
+ if (typeof openFile === "function") try {
283
+ openFile(path);
284
+ } catch {}
285
+ }
286
+ }, React.createElement("span", { className: "wbd-card-icon" }, icon), React.createElement("span", { className: "wbd-card-name" }, name), React.createElement("span", { className: "wbd-card-sub" }, sub));
287
+ }
288
+ return function FileCards(props) {
289
+ const matched = props.matched;
290
+ if (matched === null || matched === void 0 || !Array.isArray(matched.refs) || matched.refs.length === 0) return null;
291
+ return React.createElement("div", { className: "wbd-cards" }, React.createElement("span", { className: "wbd-cards-label" }, "📎 消息引用的文件"), matched.refs.map((path, i) => React.createElement(FileCard, {
292
+ key: String(path) + ":" + i,
293
+ path,
294
+ openFile: props.openFile
295
+ })));
296
+ };
297
+ }
298
+ //#endregion
299
+ //#region src/client/components/overlay.tsx
300
+ /**
301
+ * 全屏拖拽遮罩 + toast(挂在 shell.overlay 列表槽)。
302
+ * 遮罩只在拖拽含非图片文件/文件夹时出现(纯图片拖放交给原生图片轨道);
303
+ * 层本身点击穿透,遮罩激活时开启 pointer-events 承接 drop。
304
+ */
305
+ function createOverlayComponent(React, bus) {
306
+ return function WorkbuddyOverlay() {
307
+ const [state, setState] = React.useState(bus.get());
308
+ React.useEffect(() => bus.subscribe(setState), []);
309
+ return React.createElement("div", { className: "wbd-overlay" }, state.active ? React.createElement("div", { className: "wbd-shield" }, React.createElement("div", { className: "wbd-shield-inner" }, React.createElement("div", { className: "wbd-shield-icon" }, "📥"), React.createElement("div", { className: "wbd-shield-title" }, "松开以接收文件"), React.createElement("div", { className: "wbd-shield-sub" }, state.count + " 项 · 将作为引用气泡插入输入框光标处"), React.createElement("div", { className: "wbd-shield-hint" }, "文件(含图片)将缓存至 ~/.dsh-drops 并引用绝对路径"))) : null, state.toast ? React.createElement("div", { className: "wbd-toast" + (state.toast.level === "error" ? " wbd-error" : "") }, state.toast.text) : null);
310
+ };
311
+ }
312
+ //#endregion
313
+ //#region src/client/components/pick-button.tsx
314
+ /**
315
+ * 📎 引用按钮(挂在 conversation.input.left 列表槽):
316
+ * 统一走 <input type=file>(多选 / webkitdirectory)→ 立即插入气泡 →
317
+ * 后台缓存(所有浏览器行为一致)。
318
+ */
319
+ function createPickButtonComponent(React, bus, handlers) {
320
+ return function PickButton() {
321
+ const [open, setOpen] = React.useState(false);
322
+ const newBatch = () => "drop-" + Date.now().toString(36);
323
+ const pickFolder = async () => {
324
+ setOpen(false);
325
+ const input = document.createElement("input");
326
+ input.type = "file";
327
+ input.setAttribute("webkitdirectory", "");
328
+ input.onchange = () => {
329
+ const files = [];
330
+ const tops = [];
331
+ for (const f of Array.from(input.files ?? [])) {
332
+ files.push({
333
+ rel: f.webkitRelativePath || f.name,
334
+ name: f.name,
335
+ size: f.size,
336
+ file: f
337
+ });
338
+ const top = (f.webkitRelativePath || "").split("/")[0];
339
+ if (top !== "" && !tops.includes(top)) tops.push(top);
340
+ }
341
+ if (files.length > 0) handlers.acceptTree(files, tops, newBatch());
342
+ };
343
+ input.click();
344
+ };
345
+ const pickFiles = async () => {
346
+ setOpen(false);
347
+ const input = document.createElement("input");
348
+ input.type = "file";
349
+ input.multiple = true;
350
+ input.onchange = () => {
351
+ const files = [];
352
+ for (const f of Array.from(input.files ?? [])) files.push({
353
+ rel: f.name,
354
+ name: f.name,
355
+ size: f.size,
356
+ file: f
357
+ });
358
+ if (files.length > 0) handlers.acceptTree(files, [], newBatch());
359
+ };
360
+ input.click();
361
+ };
362
+ return React.createElement("div", { className: "wbd-pick" }, React.createElement("button", {
363
+ type: "button",
364
+ className: "wbd-pick-btn",
365
+ title: "引用文件/文件夹(也可直接拖拽或粘贴)",
366
+ onClick: () => setOpen(!open)
367
+ }, "📎"), open ? React.createElement("div", { className: "wbd-pick-menu" }, React.createElement("button", {
368
+ type: "button",
369
+ onClick: pickFiles
370
+ }, "选择文件…"), React.createElement("button", {
371
+ type: "button",
372
+ onClick: pickFolder
373
+ }, "选择文件夹…(保留目录树)")) : null);
374
+ };
375
+ }
376
+ //#endregion
377
+ //#region src/client/css.ts
378
+ /** 包内样式(styles.insert 注入,随插件 Run 生命周期清理) */
379
+ const CSS = [
380
+ ".wbd-overlay{position:fixed;inset:0;pointer-events:none;z-index:2147483000}",
381
+ ".wbd-shield{position:fixed;inset:0;pointer-events:auto;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--dsw-color-bg,#0e1116) 72%,transparent);backdrop-filter:blur(2px)}",
382
+ ".wbd-shield-inner{min-width:340px;max-width:520px;padding:28px 36px;text-align:center;border:2px dashed var(--dsw-alias-border-l3,#4c9aff);border-radius:14px;background:var(--dsw-color-bg-elevated,#161b24);box-shadow:0 12px 48px rgba(0,0,0,.45)}",
383
+ ".wbd-shield-icon{font-size:34px;line-height:1}",
384
+ ".wbd-shield-title{margin-top:10px;font-size:17px;font-weight:600;color:var(--dsw-alias-label-primary,#f2f4f8)}",
385
+ ".wbd-shield-sub{margin-top:6px;font-size:13px;color:var(--dsw-alias-label-secondary,#aab2c0)}",
386
+ ".wbd-shield-hint{margin-top:10px;font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280)}",
387
+ ".wbd-toast{position:fixed;right:24px;bottom:132px;max-width:360px;padding:9px 14px;border-radius:10px;background:var(--dsw-color-bg-elevated,#1c222e);border:1px solid var(--dsw-alias-border-l3,#3a4252);color:var(--dsw-alias-label-primary,#f2f4f8);font-size:13px;box-shadow:0 8px 28px rgba(0,0,0,.35);pointer-events:auto}",
388
+ ".wbd-toast.wbd-error{border-color:#b3453f}",
389
+ ".wbd-cards{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:14px 0 4px;font-size:13px}",
390
+ ".wbd-cards-label{color:var(--dsw-alias-label-tertiary,#6b7280);margin-right:4px}",
391
+ ".wbd-card{display:inline-flex;align-items:center;gap:7px;max-width:340px;padding:4px 10px 4px 8px;border:1px solid var(--dsw-alias-border-l2,#2c3342);border-radius:8px;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.04));color:var(--dsw-alias-label-primary,#f2f4f8);font:inherit;font-size:13px;cursor:pointer;text-align:left}",
392
+ ".wbd-card:hover{border-color:var(--dsw-alias-border-l3,#4c9aff)}",
393
+ ".wbd-card-icon{flex:none;font-size:15px}",
394
+ ".wbd-card-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
395
+ ".wbd-card-sub{flex:none;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}",
396
+ ".wbd-pick{position:relative;display:inline-flex}",
397
+ ".wbd-pick-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#aab2c0);font-size:15px;cursor:pointer}",
398
+ ".wbd-pick-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06));color:var(--dsw-alias-label-primary,#f2f4f8)}",
399
+ ".wbd-pick-menu{position:absolute;left:0;bottom:calc(100% + 8px);display:flex;flex-direction:column;min-width:220px;padding:6px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,#2c3342);background:var(--dsw-color-bg-elevated,#1c222e);box-shadow:0 10px 32px rgba(0,0,0,.4);z-index:10}",
400
+ ".wbd-pick-menu button{display:block;width:100%;padding:8px 10px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary,#f2f4f8);font:inherit;font-size:13px;text-align:left;cursor:pointer}",
401
+ ".wbd-pick-menu button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}",
402
+ "/* ---- 引用气泡换肤:主题色圆角矩形(原子删除由输入机原生保证) ---- */",
403
+ "/* 关键:padding 用等量负 margin 抵消、描边用 box-shadow(不占布局)—— */",
404
+ "/* 气泡外部宽度与 textarea 字符宽度完全一致,backdrop 与光标严格对齐 */",
405
+ "[data-decoration=\"chip\"]{padding:0 8px !important;margin:0 -8px !important;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 45%,transparent);color:var(--dsw-alias-brand-primary,#4c9aff) !important;font-weight:500}",
406
+ "[data-decoration=\"chip\"]:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 22%,transparent) !important}",
407
+ "[data-decoration=\"chip\"][data-invalid=\"true\"]{color:var(--dsw-alias-state-error-primary,#e56a64) !important;background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 45%,transparent) !important}",
408
+ "[data-decoration=\"chip\"] [class*=\"chipTriggerGlyph\"]{font-size:12px;opacity:.8}"
409
+ ].join("\n");
410
+ //#endregion
411
+ //#region src/client/definitions.ts
412
+ const boundaryDef = {
413
+ kind: "workbuddy-turn-boundary",
414
+ match: (event) => event.type === "turn/start" ? {
415
+ id: String(event.data.turn),
416
+ role: "start"
417
+ } : event.type === "turn/end" ? {
418
+ id: String(event.data.turn),
419
+ role: "update"
420
+ } : null,
421
+ start: (_context, match) => {
422
+ if (match.event.type !== "turn/start") throw new Error("workbuddy-turn-boundary start requires turn/start");
423
+ return {
424
+ turn: match.event.data.turn,
425
+ startSeq: match.event.seq
426
+ };
427
+ },
428
+ update: (context) => context.state
429
+ };
430
+ const fileRefsDef = {
431
+ kind: "workbuddy-file-refs",
432
+ match: (event) => {
433
+ if (event.type === "user/message" && event.data?.source?.kind === "user" && event.data.id !== void 0) return {
434
+ id: String(event.data.id),
435
+ role: "start"
436
+ };
437
+ if (event.type === "turn/start" && event.data.turn !== void 0) return {
438
+ id: "turn-" + String(event.data.turn),
439
+ role: "start"
440
+ };
441
+ return null;
442
+ },
443
+ start: (context, match, reader) => {
444
+ if (match.event.type === "turn/start") {
445
+ let refs = [];
446
+ const prev = reader.previous("workbuddy-file-refs");
447
+ const prevBoundary = reader.previous("workbuddy-turn-boundary");
448
+ if (prev !== void 0 && prev.state !== void 0 && Array.isArray(prev.state.refs)) {
449
+ if (prevBoundary === void 0 || prevBoundary.state === void 0 || prev.startSeq > prevBoundary.startSeq) refs = prev.state.refs;
450
+ }
451
+ return {
452
+ turn: match.event.data.turn,
453
+ refs
454
+ };
455
+ }
456
+ const ev = match.event;
457
+ return {
458
+ seq: ev.seq,
459
+ refs: extractRefs(ev.data?.content ?? [])
460
+ };
461
+ },
462
+ update: (context) => context.state,
463
+ buildLocationData: (context, scope) => {
464
+ if (scope !== "turn") return null;
465
+ const s = context.state;
466
+ if (s === void 0 || s.turn === void 0 || !Array.isArray(s.refs) || s.refs.length === 0) return null;
467
+ return {
468
+ kind: "turn",
469
+ turn: s.turn,
470
+ key: "workbuddy-file-refs",
471
+ value: { refs: s.refs }
472
+ };
473
+ }
474
+ };
475
+ /** turnTail chain selector:仅当该轮次存在文件引用时返回 matched,避免抢占其他链条目 */
476
+ function selectTurnFileRefs(owner) {
477
+ const t = owner !== null && owner !== void 0 ? owner.turn : void 0;
478
+ if (t === void 0 || t.data === void 0 || typeof t.data.get !== "function") return null;
479
+ const data = t.data.get("workbuddy-file-refs");
480
+ if (data === void 0 || data === null || !Array.isArray(data.refs) || data.refs.length === 0) return null;
481
+ return { refs: data.refs };
482
+ }
483
+ //#endregion
484
+ //#region src/client/lib/bus.ts
485
+ function createDropBus() {
486
+ let state = {
487
+ active: false,
488
+ count: 0,
489
+ toast: null
490
+ };
491
+ const subs = /* @__PURE__ */ new Set();
492
+ let timer = null;
493
+ const get = () => state;
494
+ const set = (next) => {
495
+ state = next;
496
+ for (const fn of subs) fn(state);
497
+ };
498
+ const subscribe = (fn) => {
499
+ subs.add(fn);
500
+ return () => {
501
+ subs.delete(fn);
502
+ };
503
+ };
504
+ const toast = (text, level = "info") => {
505
+ if (timer !== null) clearTimeout(timer);
506
+ set({
507
+ ...state,
508
+ toast: {
509
+ text: String(text),
510
+ level
511
+ }
512
+ });
513
+ timer = setTimeout(() => {
514
+ timer = null;
515
+ set({
516
+ ...state,
517
+ toast: null
518
+ });
519
+ }, 4600);
520
+ };
521
+ return {
522
+ get,
523
+ set,
524
+ subscribe,
525
+ toast
526
+ };
527
+ }
528
+ //#endregion
529
+ //#region src/client/lib/drop.ts
530
+ /** 任何文件拖拽都接管;items 不可用(Firefox dragenter/dragover)时按 types 兜底 */
531
+ function interceptable(dt) {
532
+ if (dt === null || dt === void 0) return false;
533
+ if (Array.from(dt.types ?? []).includes("Files")) return true;
534
+ if (dt.items) {
535
+ for (const it of Array.from(dt.items)) if (it.kind === "file") return true;
536
+ }
537
+ return false;
538
+ }
539
+ function countFiles(dt) {
540
+ let n = 0;
541
+ for (const it of Array.from(dt.items)) if (it.kind === "file") n += 1;
542
+ return n;
543
+ }
544
+ /**
545
+ * 关键:DataTransfer 只在事件同步阶段有效 —— getAsFile / webkitGetAsEntry
546
+ * 必须在事件处理器内、任何 await 之前完成调用,否则浏览器清空 DataTransfer
547
+ * 后全部返回 null。本函数专门在同步阶段收集 File / Entry。
548
+ */
549
+ function syncCollect(dt) {
550
+ const synced = [];
551
+ for (const it of Array.from(dt.items)) {
552
+ if (it.kind !== "file") continue;
553
+ let entry = null;
554
+ try {
555
+ const getter = it.webkitGetAsEntry ?? it.getAsEntry;
556
+ entry = typeof getter === "function" ? getter() : null;
557
+ } catch {
558
+ entry = null;
559
+ }
560
+ let file = null;
561
+ try {
562
+ file = typeof it.getAsFile === "function" ? it.getAsFile() : null;
563
+ } catch {
564
+ file = null;
565
+ }
566
+ if (entry === null && file === null) continue;
567
+ synced.push({
568
+ entry,
569
+ file
570
+ });
571
+ }
572
+ return synced;
573
+ }
574
+ function createDropHandlers(deps) {
575
+ const { bus, insert } = deps;
576
+ const submitRefs = async (refs, jobs, batch) => {
577
+ if (refs.length === 0) {
578
+ bus.toast("无法读取拖入的内容", "error");
579
+ return;
580
+ }
581
+ const inserted = await insert(refs);
582
+ if (inserted > 0) bus.toast("已引用 " + inserted + " 项,文件正在后台缓存");
583
+ else bus.toast("未能插入引用(见上方提示)", "error");
584
+ deps.enqueueUpload(jobs, batch);
585
+ };
586
+ /** 文件选择框路径(已有 TreeFile 列表) */
587
+ const acceptTree = async (files, dirTops, batch) => {
588
+ const root = await deps.ensureRoot();
589
+ if (root === null) {
590
+ bus.toast("无法获取缓存目录,请重试", "error");
591
+ return;
592
+ }
593
+ const refs = [];
594
+ for (const top of dirTops) {
595
+ const dirPath = root + "/" + batch + "/" + top;
596
+ refs.push({
597
+ label: top,
598
+ reference: {
599
+ source: "workbuddy",
600
+ ref: mentionFor(dirPath, true),
601
+ label: top,
602
+ appearance: "folder",
603
+ clipboardText: mentionFor(dirPath, true)
604
+ }
605
+ });
606
+ }
607
+ for (const f of files) {
608
+ const path = root + "/" + batch + "/" + f.rel;
609
+ refs.push({
610
+ label: f.name,
611
+ reference: {
612
+ source: "workbuddy",
613
+ ref: mentionFor(path, false),
614
+ label: f.name,
615
+ appearance: "file",
616
+ clipboardText: mentionFor(path, false)
617
+ }
618
+ });
619
+ }
620
+ await submitRefs(refs, files.map((f) => ({
621
+ kind: "file",
622
+ file: f.file,
623
+ rel: f.rel,
624
+ name: f.name
625
+ })), batch);
626
+ };
627
+ /**
628
+ * 核心:先按预分配路径立即插入气泡,再交给后台缓存。
629
+ * synced 必须在事件内同步收集完毕(见 syncCollect)。
630
+ */
631
+ const acceptAndInsert = async (synced, batch) => {
632
+ const root = await deps.ensureRoot();
633
+ if (root === null) {
634
+ bus.toast("无法获取缓存目录,请重试", "error");
635
+ return;
636
+ }
637
+ const refs = [];
638
+ const jobs = [];
639
+ for (const s of synced) {
640
+ const { entry, file } = s;
641
+ if (entry !== null && entry !== void 0 && entry.isDirectory) {
642
+ const top = entry.name;
643
+ const dirPath = root + "/" + batch + "/" + top;
644
+ refs.push({
645
+ label: top,
646
+ reference: {
647
+ source: "workbuddy",
648
+ ref: mentionFor(dirPath, true),
649
+ label: top,
650
+ appearance: "folder",
651
+ clipboardText: mentionFor(dirPath, true)
652
+ }
653
+ });
654
+ jobs.push({
655
+ kind: "dir",
656
+ entry
657
+ });
658
+ continue;
659
+ }
660
+ if (file !== null && file !== void 0) {
661
+ const rel = file.name;
662
+ const path = root + "/" + batch + "/" + rel;
663
+ refs.push({
664
+ label: file.name,
665
+ reference: {
666
+ source: "workbuddy",
667
+ ref: mentionFor(path, false),
668
+ label: file.name,
669
+ appearance: "file",
670
+ clipboardText: mentionFor(path, false)
671
+ }
672
+ });
673
+ jobs.push({
674
+ kind: "file",
675
+ file,
676
+ rel,
677
+ name: file.name
678
+ });
679
+ continue;
680
+ }
681
+ if (entry !== null && entry !== void 0 && entry.isFile) {
682
+ const fe = entry;
683
+ const f = await new Promise((resolve2) => fe.file((ff) => resolve2(ff), () => resolve2(null)));
684
+ if (f !== null && f !== void 0) {
685
+ const rel = f.name;
686
+ const path = root + "/" + batch + "/" + rel;
687
+ refs.push({
688
+ label: f.name,
689
+ reference: {
690
+ source: "workbuddy",
691
+ ref: mentionFor(path, false),
692
+ label: f.name,
693
+ appearance: "file",
694
+ clipboardText: mentionFor(path, false)
695
+ }
696
+ });
697
+ jobs.push({
698
+ kind: "file",
699
+ file: f,
700
+ rel,
701
+ name: f.name
702
+ });
703
+ }
704
+ }
705
+ }
706
+ await submitRefs(refs, jobs, batch);
707
+ };
708
+ const installListeners = () => {
709
+ let dragDepth = 0;
710
+ const onDragEnter = (e) => {
711
+ if (!interceptable(e.dataTransfer)) return;
712
+ e.preventDefault();
713
+ e.stopPropagation();
714
+ dragDepth += 1;
715
+ bus.set({
716
+ ...bus.get(),
717
+ active: true,
718
+ count: countFiles(e.dataTransfer)
719
+ });
720
+ };
721
+ const onDragOver = (e) => {
722
+ if (!interceptable(e.dataTransfer)) return;
723
+ e.preventDefault();
724
+ e.stopPropagation();
725
+ if (!bus.get().active) {
726
+ dragDepth = 1;
727
+ bus.set({
728
+ ...bus.get(),
729
+ active: true,
730
+ count: countFiles(e.dataTransfer)
731
+ });
732
+ }
733
+ };
734
+ const onDragLeave = (e) => {
735
+ if (!interceptable(e.dataTransfer)) return;
736
+ e.preventDefault();
737
+ e.stopPropagation();
738
+ if (dragDepth > 0) dragDepth -= 1;
739
+ if (dragDepth === 0) bus.set({
740
+ ...bus.get(),
741
+ active: false,
742
+ count: 0
743
+ });
744
+ };
745
+ const onDrop = (e) => {
746
+ const dt = e.dataTransfer;
747
+ if (dt === null || dt === void 0) return;
748
+ if (!interceptable(dt)) return;
749
+ e.preventDefault();
750
+ e.stopPropagation();
751
+ dragDepth = 0;
752
+ bus.set({
753
+ ...bus.get(),
754
+ active: false,
755
+ count: 0
756
+ });
757
+ const synced = syncCollect(dt);
758
+ if (synced.length === 0) return;
759
+ acceptAndInsert(synced, "drop-" + Date.now().toString(36)).catch((err) => {
760
+ console.error("[workbuddy] drop 处理失败:", err);
761
+ bus.toast("拖入处理失败:" + String(err?.message ?? err), "error");
762
+ });
763
+ };
764
+ const onDragEnd = () => {
765
+ dragDepth = 0;
766
+ bus.set({
767
+ ...bus.get(),
768
+ active: false,
769
+ count: 0
770
+ });
771
+ };
772
+ const onPaste = (e) => {
773
+ const cd = e.clipboardData;
774
+ if (cd === null || cd === void 0 || !cd.items) return;
775
+ let hasFile = false;
776
+ for (const it of Array.from(cd.items)) if (it.kind === "file") {
777
+ hasFile = true;
778
+ break;
779
+ }
780
+ if (!hasFile) return;
781
+ e.preventDefault();
782
+ e.stopPropagation();
783
+ const synced = syncCollect(cd);
784
+ if (synced.length === 0) return;
785
+ acceptAndInsert(synced, "drop-" + Date.now().toString(36)).catch((err) => {
786
+ console.error("[workbuddy] 粘贴处理失败:", err);
787
+ bus.toast("粘贴处理失败:" + String(err?.message ?? err), "error");
788
+ });
789
+ };
790
+ window.addEventListener("dragenter", onDragEnter, true);
791
+ window.addEventListener("dragover", onDragOver, true);
792
+ window.addEventListener("dragleave", onDragLeave, true);
793
+ window.addEventListener("drop", onDrop, true);
794
+ window.addEventListener("dragend", onDragEnd, true);
795
+ window.addEventListener("paste", onPaste, true);
796
+ return () => {
797
+ window.removeEventListener("dragenter", onDragEnter, true);
798
+ window.removeEventListener("dragover", onDragOver, true);
799
+ window.removeEventListener("dragleave", onDragLeave, true);
800
+ window.removeEventListener("drop", onDrop, true);
801
+ window.removeEventListener("dragend", onDragEnd, true);
802
+ window.removeEventListener("paste", onPaste, true);
803
+ };
804
+ };
805
+ return {
806
+ acceptAndInsert,
807
+ acceptTree,
808
+ installListeners
809
+ };
810
+ }
811
+ //#endregion
812
+ //#region src/client/lib/insert.ts
813
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
814
+ function readCaret(fallbackLen) {
815
+ const el = document.activeElement;
816
+ if (el !== null && el !== void 0 && String(el.tagName).toUpperCase() === "TEXTAREA" && typeof el.selectionStart === "number") return el.selectionStart;
817
+ return fallbackLen;
818
+ }
819
+ function createInsertPipeline(deps) {
820
+ return async function insertItems(items) {
821
+ const sessionId = deps.sessions.list.getSnapshot().current;
822
+ if (sessionId === void 0) {
823
+ deps.toast("请先打开或新建一个会话,再拖入文件", "error");
824
+ return 0;
825
+ }
826
+ let shell = null;
827
+ try {
828
+ shell = deps.conversation.input.shell(sessionId);
829
+ } catch {
830
+ shell = null;
831
+ }
832
+ let inserted = 0;
833
+ let firstCaret = null;
834
+ for (const item of items) if (shell !== null && shell !== void 0) {
835
+ let ok = false;
836
+ for (let attempt = 0; attempt < 8 && !ok; attempt += 1) {
837
+ try {
838
+ const st = shell.state.getSnapshot();
839
+ let caret = st.draft.length;
840
+ if (firstCaret === null) {
841
+ firstCaret = readCaret(st.draft.length);
842
+ caret = firstCaret;
843
+ }
844
+ const pos = Math.min(caret, st.draft.length);
845
+ ok = shell.insertReference(item.reference, {
846
+ start: pos,
847
+ end: pos,
848
+ draftRev: st.draftRev
849
+ });
850
+ } catch {
851
+ ok = false;
852
+ }
853
+ if (!ok) await sleep(90);
854
+ }
855
+ if (ok) {
856
+ inserted += 1;
857
+ continue;
858
+ }
859
+ try {
860
+ const st = shell.state.getSnapshot();
861
+ const pos = Math.min(firstCaret !== null ? firstCaret : st.draft.length, st.draft.length);
862
+ const mention = typeof item.reference.ref === "string" ? item.reference.ref : String(item.label);
863
+ const next = st.draft.slice(0, pos) + mention + " " + st.draft.slice(pos);
864
+ shell.setDraft(next);
865
+ inserted += 1;
866
+ continue;
867
+ } catch (err2) {
868
+ try {
869
+ shell.notify("error", "未能插入引用「" + item.label + "」:" + String(err2?.message ?? err2));
870
+ } catch {}
871
+ }
872
+ } else {
873
+ const text = item.reference.ref;
874
+ const el = document.activeElement;
875
+ if (el !== null && el !== void 0 && String(el.tagName).toUpperCase() === "TEXTAREA") try {
876
+ document.execCommand("insertText", false, text + " ");
877
+ inserted += 1;
878
+ } catch {}
879
+ else deps.toast("输入区不可用,未能插入「" + item.label + "」", "error");
880
+ }
881
+ return inserted;
882
+ };
883
+ }
884
+ //#endregion
885
+ //#region src/client/app.ts
886
+ /**
887
+ * Client 半侧实现(factory 模式)。
888
+ *
889
+ * DSH 客户端模块加载器要求 client 产物是「普通副作用脚本」:加载时调用
890
+ * window.__ModuleLoader__.load({ id, factory }),factory 签名 (require) => module,
891
+ * 返回 { apply, inject, name }。react 通过 factory 的 require('react') 取得
892
+ * (参考 dsh-pet 的 src/client/app.ts 与 index.ts)。
893
+ *
894
+ * 纯逻辑模块(lib/*、at-source、definitions)不依赖 react,可安全地
895
+ * 被 tsdown 内联到 bundle 顶层;组件在 factory 内以注入的 React 构造。
896
+ */
897
+ function makeFactory() {
898
+ return function workbuddyClientFactory(require) {
899
+ const React = require("react");
900
+ return {
901
+ name: "workbuddy-files",
902
+ apply(ctx) {
903
+ const get = (name) => ctx.get(name);
904
+ const sessions = get("sessions");
905
+ const slots = get("slots");
906
+ const conversation = get("conversation");
907
+ const inputTriggers = get("inputTriggers");
908
+ const conversationEvents = get("conversationEvents");
909
+ const styles = get("styles");
910
+ if (slots === void 0) return;
911
+ const insertStyles = () => styles !== void 0 ? styles.insert(CSS) : () => {};
912
+ const effect = ctx.effect.bind(ctx);
913
+ effect(insertStyles, "workbuddy: styles");
914
+ const bus = createDropBus();
915
+ let rootCache = null;
916
+ dropsHome().then((r) => {
917
+ rootCache = r;
918
+ });
919
+ const ensureRoot = async () => {
920
+ if (rootCache !== null) return rootCache;
921
+ const r = await dropsHome();
922
+ rootCache = r;
923
+ return r;
924
+ };
925
+ const enqueueUpload = (jobs, batch) => {
926
+ runUploadJobs(jobs, batch).then(({ ok, failed }) => {
927
+ if (failed.length > 0) bus.toast("缓存失败 " + failed.length + " 项:" + failed.slice(0, 2).join(";") + (failed.length > 2 ? "…" : ""), "error");
928
+ else if (ok > 0) bus.toast("后台缓存完成:" + ok + " 个文件已就绪");
929
+ }).catch((err) => {
930
+ bus.toast("后台缓存失败:" + String(err?.message ?? err), "error");
931
+ });
932
+ };
933
+ const handlers = createDropHandlers({
934
+ bus,
935
+ insert: createInsertPipeline({
936
+ sessions,
937
+ conversation,
938
+ toast: bus.toast
939
+ }),
940
+ ensureRoot,
941
+ enqueueUpload
942
+ });
943
+ effect(() => handlers.installListeners(), "workbuddy: window listeners");
944
+ if (inputTriggers !== void 0) {
945
+ const source = createAtSource();
946
+ effect(() => inputTriggers.registerSource(source), "workbuddy: @ source");
947
+ }
948
+ if (conversationEvents !== void 0) effect(() => {
949
+ const d1 = conversationEvents.register(boundaryDef);
950
+ const d2 = conversationEvents.register(fileRefsDef);
951
+ return () => {
952
+ if (typeof d1 === "function") d1();
953
+ if (typeof d2 === "function") d2();
954
+ };
955
+ }, "workbuddy: conversation definitions");
956
+ slots.inject("shell.overlay", () => slots.register({
957
+ name: "shell.overlay",
958
+ id: "workbuddy-drop",
959
+ order: 300,
960
+ label: "WorkBuddy 拖拽遮罩"
961
+ }, createOverlayComponent(React, bus)));
962
+ slots.inject("conversation.input.left", () => slots.register({
963
+ name: "conversation.input.left",
964
+ id: "workbuddy-pick",
965
+ order: 0,
966
+ label: "引用文件/文件夹"
967
+ }, createPickButtonComponent(React, bus, handlers)));
968
+ slots.inject("conversation.chat.turnTail", () => slots.register({
969
+ name: "conversation.chat.turnTail",
970
+ select: selectTurnFileRefs
971
+ }, createFileCardsComponent(React)));
972
+ console.log("[workbuddy-files] client 就绪:拖入即插气泡 + 后台缓存 / 统一遮罩 / 文件卡片");
973
+ }
974
+ };
975
+ };
976
+ }
977
+ //#endregion
978
+ //#region src/client/index.ts
979
+ window.__ModuleLoader__.load({
980
+ id: "dsh-workbuddy-files",
981
+ factory: makeFactory()
982
+ });
983
+ //#endregion
984
+ export {};