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/index.js ADDED
@@ -0,0 +1,348 @@
1
+ import { homedir } from "node:os";
2
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join, normalize, resolve, sep } from "node:path";
4
+ import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ //#region src/host/index.ts
6
+ /**
7
+ * dsh-workbuddy-files · Host 半侧
8
+ *
9
+ * 职责:
10
+ * 1. 文件落地缓存 `~/.dsh-drops/`(保留拖入目录树结构),通过 webServer 路由
11
+ * `/workbuddy-drops` 接收浏览器 fetch 的二进制 POST(无需 base64,支持大文件);
12
+ * 2. 注册 `read_document` 模型工具:解析 `@"path"` / `@path` 引用并返回内容。
13
+ *
14
+ * 真实插件包的 Host 半侧运行在 DSH 的 Node 进程里(非沙箱),可以直接使用
15
+ * node:fs —— 这也是 dsh-pet 等第三方插件的标准写法(参考其 src/host/index.ts)。
16
+ */
17
+ /** 插件行 id(与 cordis.patch.yml 的 id 一致) */
18
+ const name = "workbuddy-files";
19
+ /** 硬依赖:Web 服务器路由注册表 */
20
+ const inject = ["webServer"];
21
+ function resolveConfig(config = {}) {
22
+ return {
23
+ dropsDir: config.dropsDir ?? "~/.dsh-drops",
24
+ maxFileBytes: config.maxFileBytes ?? 268435456
25
+ };
26
+ }
27
+ function expandHome(dir) {
28
+ if (dir === "~" || dir.startsWith("~/")) return join(homedir(), dir.slice(1));
29
+ return dir;
30
+ }
31
+ /** 规范化相对路径:拒绝 `..`、绝对路径与控制字符(防目录穿越) */
32
+ function normRel(rel) {
33
+ const parts = String(rel).replace(/\\/g, "/").split("/");
34
+ const out = [];
35
+ for (const p of parts) {
36
+ if (p === "" || p === ".") continue;
37
+ if (p === "..") return null;
38
+ if (/[\u0000-\u001f"\r\n]/.test(p)) return null;
39
+ out.push(p);
40
+ }
41
+ return out.join("/");
42
+ }
43
+ /** 校验最终路径仍位于 drops 根目录内(第二道防线) */
44
+ function resolveDrops(root, rel) {
45
+ const candidate = normalize(join(root, rel));
46
+ const rootWithSep = root.endsWith(sep) ? root : root + sep;
47
+ if (candidate !== root && !candidate.startsWith(rootWithSep)) return void 0;
48
+ return candidate;
49
+ }
50
+ function sendJson(res, status, obj) {
51
+ const body = JSON.stringify(obj);
52
+ res.writeHead(status, {
53
+ "content-type": "application/json; charset=utf-8",
54
+ "content-length": Buffer.byteLength(body)
55
+ });
56
+ res.end(body);
57
+ }
58
+ /** 收集请求体原始字节(流式,不设上限;上限检查在写盘前按 maxFileBytes 执行) */
59
+ function readBody(req) {
60
+ return new Promise((resolve2, reject) => {
61
+ const chunks = [];
62
+ let total = 0;
63
+ req.on("data", (c) => {
64
+ chunks.push(c);
65
+ total += c.length;
66
+ });
67
+ req.on("end", () => resolve2(Buffer.concat(chunks, total)));
68
+ req.on("error", reject);
69
+ });
70
+ }
71
+ /** UTF-8 可解码性 / 二进制探测(NUL 字节即二进制) */
72
+ function looksBinary(buf) {
73
+ if (buf.includes(0)) return true;
74
+ try {
75
+ new TextDecoder("utf-8", { fatal: true }).decode(buf);
76
+ return false;
77
+ } catch {
78
+ return true;
79
+ }
80
+ }
81
+ function apply(ctx, config = {}) {
82
+ const resolved = resolveConfig(config);
83
+ const dropsDir = resolve(expandHome(resolved.dropsDir));
84
+ const webServer = ctx.webServer;
85
+ ctx.effect(() => webServer.register({
86
+ kind: "prefix",
87
+ path: "/workbuddy-drops",
88
+ handler: async (req, res) => {
89
+ const url = new URL(req.url ?? "/", "http://localhost");
90
+ const action = url.pathname.slice(16).replace(/^\/+/, "");
91
+ try {
92
+ if (action === "home") {
93
+ sendJson(res, 200, {
94
+ ok: true,
95
+ root: dropsDir
96
+ });
97
+ return;
98
+ }
99
+ if (action === "stat") {
100
+ const path = url.searchParams.get("path") ?? "";
101
+ if (path === "") {
102
+ sendJson(res, 200, {
103
+ ok: true,
104
+ exists: false,
105
+ path
106
+ });
107
+ return;
108
+ }
109
+ const info = await stat(path);
110
+ sendJson(res, 200, {
111
+ ok: true,
112
+ exists: true,
113
+ path,
114
+ type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other",
115
+ size: info.isFile() ? info.size : null
116
+ });
117
+ return;
118
+ }
119
+ if (action === "list") {
120
+ const q = (url.searchParams.get("query") ?? "").toLowerCase();
121
+ const items = [];
122
+ const add = (p, name, type, size) => {
123
+ if (items.length >= 200) return;
124
+ if (q === "" || p.toLowerCase().includes(q) || name.toLowerCase().includes(q)) items.push({
125
+ name,
126
+ path: p,
127
+ type,
128
+ size: size ?? null
129
+ });
130
+ };
131
+ if (await stat(dropsDir).catch(() => null) === null) {
132
+ sendJson(res, 200, {
133
+ ok: true,
134
+ items: []
135
+ });
136
+ return;
137
+ }
138
+ const batches = await readdir(dropsDir, { withFileTypes: true });
139
+ for (const b of batches) {
140
+ if (items.length >= 200) break;
141
+ if (b.name === ".tmp" || b.name.charAt(0) === ".") continue;
142
+ const bp = join(dropsDir, b.name);
143
+ if (b.isFile()) {
144
+ const s = await stat(bp).catch(() => null);
145
+ add(bp, b.name, "file", s?.size);
146
+ continue;
147
+ }
148
+ if (!b.isDirectory()) continue;
149
+ const children = await readdir(bp, { withFileTypes: true }).catch(() => []);
150
+ for (const c of children) {
151
+ if (items.length >= 200) break;
152
+ const cp = join(bp, c.name);
153
+ const isDir = c.isDirectory();
154
+ const s = isDir ? null : await stat(cp).catch(() => null);
155
+ add(cp, c.name, isDir ? "directory" : "file", s?.size ?? void 0);
156
+ if (isDir) {
157
+ const sub = await readdir(cp, { withFileTypes: true }).catch(() => []);
158
+ for (const s2 of sub) {
159
+ if (items.length >= 200) break;
160
+ const s2p = join(cp, s2.name);
161
+ const s2dir = s2.isDirectory();
162
+ const st2 = s2dir ? null : await stat(s2p).catch(() => null);
163
+ add(s2p, s2.name, s2dir ? "directory" : "file", st2?.size ?? void 0);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ sendJson(res, 200, {
169
+ ok: true,
170
+ items
171
+ });
172
+ return;
173
+ }
174
+ if (action === "save" && req.method === "POST") {
175
+ const rel = normRel(url.searchParams.get("rel") ?? "");
176
+ const batch = normRel(url.searchParams.get("batch") ?? "") || "misc";
177
+ if (rel === null || rel === "") {
178
+ sendJson(res, 400, {
179
+ ok: false,
180
+ error: "目标路径非法"
181
+ });
182
+ return;
183
+ }
184
+ const dst = resolveDrops(dropsDir, batch + "/" + rel);
185
+ if (dst === void 0) {
186
+ sendJson(res, 400, {
187
+ ok: false,
188
+ error: "目标路径越界"
189
+ });
190
+ return;
191
+ }
192
+ const body = await readBody(req);
193
+ if (resolved.maxFileBytes > 0 && body.length > resolved.maxFileBytes) {
194
+ sendJson(res, 413, {
195
+ ok: false,
196
+ error: "文件超过大小上限 " + Math.floor(resolved.maxFileBytes / 1048576) + "MB"
197
+ });
198
+ return;
199
+ }
200
+ await mkdir(dirname(dst), { recursive: true });
201
+ await writeFile(dst, body);
202
+ sendJson(res, 200, {
203
+ ok: true,
204
+ path: dst,
205
+ rel: batch + "/" + rel,
206
+ root: dropsDir
207
+ });
208
+ return;
209
+ }
210
+ sendJson(res, 404, {
211
+ ok: false,
212
+ error: "unknown action: " + action
213
+ });
214
+ } catch (err) {
215
+ sendJson(res, 500, {
216
+ ok: false,
217
+ error: String(err?.message ?? err)
218
+ });
219
+ }
220
+ }
221
+ }), "workbuddy: /workbuddy-drops route");
222
+ const readDocumentTool = defineTool({
223
+ name: "read_document",
224
+ description: "解析并读取用户消息中拖拽/粘贴/@ 产生的文件引用(支持 @\"C:\\path\\file.pdf\"、@path 或裸绝对路径)。文本类文件(代码、文档、数据)直接返回内容;文件夹引用返回目录树;图片、二进制与超大文件返回元数据与读取建议。",
225
+ parameters: {
226
+ reference: {
227
+ type: "string",
228
+ required: true,
229
+ description: "文件引用:消息中的 @\"...\" 或 @... 令牌,或直接给出绝对路径"
230
+ },
231
+ max_chars: {
232
+ type: "number",
233
+ description: "文本内容返回上限(默认 60000 字符)"
234
+ }
235
+ },
236
+ output: {
237
+ schema: {
238
+ type: "object",
239
+ additionalProperties: true
240
+ },
241
+ render: (_args, value) => [{
242
+ type: "text",
243
+ text: JSON.stringify(value)
244
+ }]
245
+ },
246
+ execute: async (args, _exec) => {
247
+ const maxChars = typeof args.max_chars === "number" && args.max_chars > 0 ? Math.floor(args.max_chars) : 6e4;
248
+ const raw = String(args.reference ?? "").trim();
249
+ if (raw === "") return {
250
+ ok: false,
251
+ error: "reference 为空"
252
+ };
253
+ if (raw.startsWith("dsh-drop://")) return {
254
+ ok: false,
255
+ error: "该引用是浏览器端零上传直引(FS Access API 句柄),发送消息时已物化到本地缓存。请用消息中的真实路径重新读取。"
256
+ };
257
+ let path = raw;
258
+ if (path.charAt(0) === "@") path = path.slice(1).trim();
259
+ if (path.length >= 2 && path.charAt(0) === "\"" && path.charAt(path.length - 1) === "\"") path = path.slice(1, -1);
260
+ path = path.replace(/\/+$/, "");
261
+ try {
262
+ const info = await stat(path);
263
+ const base = path.split(/[\\/]/).pop();
264
+ if (info.isDirectory()) {
265
+ const entries = await readdir(path, { withFileTypes: true });
266
+ const tree = entries.slice(0, 300).map(async (e) => {
267
+ const st = e.isFile() ? await stat(join(path, e.name)).catch(() => null) : null;
268
+ return {
269
+ name: e.name,
270
+ type: e.isDirectory() ? "directory" : "file",
271
+ size: st?.size ?? null
272
+ };
273
+ });
274
+ return {
275
+ ok: true,
276
+ path,
277
+ name: base,
278
+ kind: "directory",
279
+ entries: await Promise.all(tree),
280
+ truncated: entries.length > 300
281
+ };
282
+ }
283
+ if (!info.isFile()) return {
284
+ ok: false,
285
+ error: "目标不是普通文件: " + path
286
+ };
287
+ const size = info.size;
288
+ if (/\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i.test(base ?? "")) return {
289
+ ok: true,
290
+ path,
291
+ name: base,
292
+ kind: "image",
293
+ size,
294
+ hint: "图片文件:请使用 read_image 工具以该路径直接查看。"
295
+ };
296
+ if (size > 15e5) return {
297
+ ok: true,
298
+ path,
299
+ name: base,
300
+ kind: "large",
301
+ size,
302
+ hint: "文件过大,未内联内容;可请用户拆分,或用支持分块读取的工具处理。"
303
+ };
304
+ const buf = await readFile(path);
305
+ if (looksBinary(buf)) return {
306
+ ok: true,
307
+ path,
308
+ name: base,
309
+ kind: "binary",
310
+ size,
311
+ hint: "二进制文件。若为常见格式,可询问用户或用宿主工具转换后读取。"
312
+ };
313
+ let content = buf.toString("utf8");
314
+ const truncated = content.length > maxChars;
315
+ if (truncated) content = content.slice(0, maxChars);
316
+ return {
317
+ ok: true,
318
+ path,
319
+ name: base,
320
+ kind: "text",
321
+ size,
322
+ content,
323
+ truncated
324
+ };
325
+ } catch (err) {
326
+ if (err?.code === "ENOENT") return {
327
+ ok: false,
328
+ error: "路径不存在: " + path
329
+ };
330
+ return {
331
+ ok: false,
332
+ error: String(err?.message ?? err)
333
+ };
334
+ }
335
+ },
336
+ presentCall: (args) => ({
337
+ card: "generic",
338
+ title: "读取引用文件",
339
+ kind: "read",
340
+ rawInput: String(args.reference ?? "")
341
+ })
342
+ });
343
+ const tools = ctx.get("tools");
344
+ ctx.effect(() => tools !== void 0 ? tools.register(readDocumentTool) : (() => {}), "workbuddy: read_document tool");
345
+ console.log("[workbuddy-files] host 就绪:drops 缓存 " + dropsDir + "(/workbuddy-drops 路由)+ read_document 工具");
346
+ }
347
+ //#endregion
348
+ export { apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "dsh-workbuddy-files",
3
+ "version": "0.1.0",
4
+ "description": "WorkBuddy 风格的 DeepSeek Harness Web 插件:拖拽/粘贴文件与文件夹生成输入框引用气泡(附绝对路径)、@ 菜单检索缓存文件、对话区文件卡片、read_document 工具。",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "workbuddy",
10
+ "drag-drop",
11
+ "file-reference",
12
+ "file-pill",
13
+ "web-ui",
14
+ "cordis"
15
+ ],
16
+ "license": "MIT",
17
+ "homepage": "https://github.com/miaoxintechnology/dsh-workbuddy-files#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/miaoxintechnology/dsh-workbuddy-files.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/miaoxintechnology/dsh-workbuddy-files/issues"
24
+ },
25
+ "type": "module",
26
+ "main": "lib/index.js",
27
+ "exports": {
28
+ ".": {
29
+ "default": "./lib/index.js"
30
+ },
31
+ "./client": {
32
+ "default": "./lib/client.js"
33
+ },
34
+ "./src/*": "./src/*",
35
+ "./cordis.patch.yml": "./cordis.patch.yml",
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "lib",
40
+ "src",
41
+ "cordis.patch.yml",
42
+ "README.md"
43
+ ],
44
+ "scripts": {
45
+ "bundle": "tsdown",
46
+ "watch": "tsdown --watch",
47
+ "typecheck": "tsc --noEmit",
48
+ "prepublishOnly": "npm run typecheck && npm run bundle"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "_comment": [
54
+ "main (lib/index.js) 是 Host 半侧入口(Cordis 插件 apply/inject)",
55
+ "exports['./client'] 是浏览器半侧入口,DSH 模块系统据此提供 /plugins/dsh-workbuddy-files/client.js",
56
+ "dsh.bundle.patch 声明本包是 bundle:dsh plugin add 后自动加入 profile 的 bundles 层栈并挂载 host 半侧",
57
+ "dsh.client 声明浏览器半侧的注入依赖与平台(web)",
58
+ "peerDependencies 是宿主(DSH)提供的模块,本包不打包它们"
59
+ ],
60
+ "dsh": {
61
+ "bundle": {
62
+ "patch": "./cordis.patch.yml"
63
+ },
64
+ "client": {
65
+ "inject": [
66
+ "@deepseek-ai/dsh-client-runtime"
67
+ ],
68
+ "platform": "web"
69
+ }
70
+ },
71
+ "peerDependencies": {
72
+ "@deepseek-ai/cordis": "^4.0.1",
73
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.1",
74
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.1",
75
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.1-rc.1",
76
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.1",
77
+ "@deepseek-ai/dsh-fs": "^0.1.1-rc.1",
78
+ "@deepseek-ai/dsh-shell": "^0.1.1-rc.1",
79
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.1",
80
+ "react": "^18.2.0"
81
+ },
82
+ "devDependencies": {
83
+ "@types/node": "^26.2.0",
84
+ "@types/react": "^18.2.0",
85
+ "tsdown": "^0.22.14",
86
+ "typescript": "^5.5.0"
87
+ }
88
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Client 半侧实现(factory 模式)。
3
+ *
4
+ * DSH 客户端模块加载器要求 client 产物是「普通副作用脚本」:加载时调用
5
+ * window.__ModuleLoader__.load({ id, factory }),factory 签名 (require) => module,
6
+ * 返回 { apply, inject, name }。react 通过 factory 的 require('react') 取得
7
+ * (参考 dsh-pet 的 src/client/app.ts 与 index.ts)。
8
+ *
9
+ * 纯逻辑模块(lib/*、at-source、definitions)不依赖 react,可安全地
10
+ * 被 tsdown 内联到 bundle 顶层;组件在 factory 内以注入的 React 构造。
11
+ */
12
+ import { createAtSource } from './at-source'
13
+ import { createFileCardsComponent } from './components/file-cards'
14
+ import { createOverlayComponent, ReactLike } from './components/overlay'
15
+ import { createPickButtonComponent } from './components/pick-button'
16
+ import { CSS } from './css'
17
+ import { boundaryDef, fileRefsDef, selectTurnFileRefs } from './definitions'
18
+ import { createDropBus } from './lib/bus'
19
+ import { createDropHandlers } from './lib/drop'
20
+ import { createInsertPipeline } from './lib/insert'
21
+ import { dropsHome, runUploadJobs } from './lib/transfer'
22
+ import type { UploadJob } from './types'
23
+
24
+ /** 工厂返回的 Cordis 客户端插件 */
25
+ export interface ClientPlugin {
26
+ apply(ctx: Record<string, unknown>): void
27
+ inject?: string[]
28
+ name?: string
29
+ }
30
+
31
+ export function makeFactory() {
32
+ return function workbuddyClientFactory(require: (m: string) => unknown): ClientPlugin {
33
+ const React = require('react') as ReactLike
34
+
35
+ return {
36
+ name: 'workbuddy-files',
37
+ apply(ctx) {
38
+ const get = (name: string) => (ctx.get as (n: string) => unknown)(name)
39
+
40
+ // ---- 能力探测(缺失则优雅退出)----
41
+ const sessions = get('sessions') as never
42
+ const slots = get('slots') as {
43
+ inject(key: string, callback: () => unknown): () => void
44
+ register(options: Record<string, unknown>, component: unknown): unknown
45
+ } | undefined
46
+ const conversation = get('conversation') as never
47
+ const inputTriggers = get('inputTriggers') as {
48
+ registerSource(source: unknown): () => void
49
+ } | undefined
50
+ const conversationEvents = get('conversationEvents') as {
51
+ register(definition: unknown): (() => void) | undefined
52
+ } | undefined
53
+ const styles = get('styles') as { insert(css: string): () => void } | undefined
54
+ if (slots === undefined) return
55
+
56
+ // ---- 包内样式 ----
57
+ const insertStyles = () => (styles !== undefined ? styles.insert(CSS) : () => {})
58
+ const effect = (ctx.effect as (fn: () => (() => void) | undefined, label?: string) => void).bind(ctx)
59
+ effect(insertStyles, 'workbuddy: styles')
60
+
61
+ // ---- 共享实例 ----
62
+ const bus = createDropBus()
63
+
64
+ // ---- 缓存根目录(启动预取;拖入时兜底拉取)----
65
+ let rootCache: string | null = null
66
+ void dropsHome().then((r) => { rootCache = r })
67
+ const ensureRoot = async (): Promise<string | null> => {
68
+ if (rootCache !== null) return rootCache
69
+ const r = await dropsHome()
70
+ rootCache = r
71
+ return r
72
+ }
73
+
74
+ // ---- 后台上传队列(完成/失败 toast)----
75
+ const enqueueUpload = (jobs: UploadJob[], batch: string): void => {
76
+ void runUploadJobs(jobs, batch).then(({ ok, failed }) => {
77
+ if (failed.length > 0) {
78
+ bus.toast('缓存失败 ' + failed.length + ' 项:' + failed.slice(0, 2).join(';') + (failed.length > 2 ? '…' : ''), 'error')
79
+ } else if (ok > 0) {
80
+ bus.toast('后台缓存完成:' + ok + ' 个文件已就绪')
81
+ }
82
+ }).catch((err) => {
83
+ bus.toast('后台缓存失败:' + String((err as Error)?.message ?? err), 'error')
84
+ })
85
+ }
86
+
87
+ // ---- 气泡注入管线 ----
88
+ const insert = createInsertPipeline({ sessions, conversation, toast: bus.toast })
89
+
90
+ // ---- 拖拽 / 粘贴处理 + 窗口级监听 ----
91
+ const handlers = createDropHandlers({ bus, insert, ensureRoot, enqueueUpload })
92
+ effect(() => handlers.installListeners(), 'workbuddy: window listeners')
93
+
94
+ // ---- @ 触发源(文件缓存分组)----
95
+ if (inputTriggers !== undefined) {
96
+ const source = createAtSource()
97
+ effect(() => inputTriggers.registerSource(source), 'workbuddy: @ source')
98
+ }
99
+
100
+ // ---- 会话事件定义 ----
101
+ if (conversationEvents !== undefined) {
102
+ effect(() => {
103
+ const d1 = conversationEvents.register(boundaryDef)
104
+ const d2 = conversationEvents.register(fileRefsDef)
105
+ return () => {
106
+ if (typeof d1 === 'function') d1()
107
+ if (typeof d2 === 'function') d2()
108
+ }
109
+ }, 'workbuddy: conversation definitions')
110
+ }
111
+
112
+ // ---- Slot 注册 ----
113
+ slots.inject('shell.overlay', () => slots.register(
114
+ { name: 'shell.overlay', id: 'workbuddy-drop', order: 300, label: 'WorkBuddy 拖拽遮罩' },
115
+ createOverlayComponent(React, bus),
116
+ ))
117
+ slots.inject('conversation.input.left', () => slots.register(
118
+ { name: 'conversation.input.left', id: 'workbuddy-pick', order: 0, label: '引用文件/文件夹' },
119
+ createPickButtonComponent(React, bus, handlers),
120
+ ))
121
+ slots.inject('conversation.chat.turnTail', () => slots.register(
122
+ { name: 'conversation.chat.turnTail', select: selectTurnFileRefs },
123
+ createFileCardsComponent(React),
124
+ ))
125
+
126
+ console.log('[workbuddy-files] client 就绪:拖入即插气泡 + 后台缓存 / 统一遮罩 / 文件卡片')
127
+ },
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,57 @@
1
+ import type { InputTriggerSource, InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
2
+ import { iconFor } from './lib/icons'
3
+ import { dropsList, mentionFor } from './lib/transfer'
4
+
5
+ /**
6
+ * @ 触发源「workbuddy」:输入 @ 时在菜单中追加「文件缓存」分组 ——
7
+ * ~/.dsh-drops 中拖拽/粘贴/选择落地的文件与目录树,支持搜索。
8
+ * 选中后由输入管线的 onPick → { insert } 在触发词位置铸造原生气泡。
9
+ *
10
+ * 工作区文件/文件夹的 @ 检索由 DSH 官方 ui-reference 源提供(文件与文件夹 +
11
+ * Session 分组),本插件与之共存,无需重复实现。
12
+ *
13
+ * 所有引用在拖入时已完成落地(真实绝对路径),因此 codec 序列化是恒等函数,
14
+ * 发送消息不可能因文件未落地而失败。
15
+ */
16
+ export function createAtSource(): InputTriggerSource {
17
+ return {
18
+ trigger: '@',
19
+ name: 'workbuddy',
20
+ order: 5,
21
+ showGroupTitle: true,
22
+ async candidates(_session, req) {
23
+ const q = (req && req.query) || ''
24
+ const items = await dropsList(q)
25
+ const out: InputTriggerCandidate[] = []
26
+ for (const it of items) {
27
+ const isDir = it.type === 'directory'
28
+ out.push({
29
+ name: (isDir ? '📁 ' : iconFor(it.name) + ' ') + it.name + (isDir ? '/' : ''),
30
+ description: it.path,
31
+ section: '文件缓存 · ~/.dsh-drops',
32
+ value: JSON.stringify({ kind: isDir ? 'folder' : 'file', name: it.name, path: it.path }),
33
+ })
34
+ }
35
+ return out.slice(0, 60)
36
+ },
37
+ onPick(pick) {
38
+ let v: { kind?: string; name?: string; path?: string } | null = null
39
+ try { v = JSON.parse(pick.candidate.value || 'null') } catch { return undefined }
40
+ if (v === null || typeof v !== 'object' || typeof v.path !== 'string') return undefined
41
+ const mention = mentionFor(v.path, v.kind === 'folder')
42
+ return {
43
+ insert: {
44
+ source: 'workbuddy',
45
+ ref: mention,
46
+ label: v.name ?? '',
47
+ appearance: v.kind === 'folder' ? 'folder' : 'file',
48
+ clipboardText: mention,
49
+ },
50
+ }
51
+ },
52
+ codec: {
53
+ clipboardText: (ref) => ref,
54
+ serialize: (ref) => Promise.resolve(ref),
55
+ },
56
+ }
57
+ }
@@ -0,0 +1,47 @@
1
+ import { formatSize, iconFor } from '../lib/icons'
2
+ import { dropsStat } from '../lib/transfer'
3
+ import type { ReactLike } from './overlay'
4
+
5
+ /**
6
+ * 对话区文件卡片(挂在 conversation.chat.turnTail 链式槽):
7
+ * 用户消息发送后,该轮次消息中引用的文件以「类型图标 + 文件名 + 大小/文件夹」卡片
8
+ * 渲染在轮次尾部;点击卡片经 owner 的 openFile 打开文件。
9
+ * selector 只匹配含文件引用的轮次(见 definitions.ts),不抢占其他链条目。
10
+ */
11
+ export function createFileCardsComponent(React: ReactLike) {
12
+ function FileCard(props: { path: string; openFile?: (path: string) => void }) {
13
+ const { path, openFile } = props
14
+ const [meta, setMeta] = React.useState<Awaited<ReturnType<typeof dropsStat>> | null>(null)
15
+ React.useEffect(() => {
16
+ let live = true
17
+ dropsStat(path).then((r) => { if (live) setMeta(r) }, () => { if (live) setMeta({ ok: false, path }) })
18
+ return () => { live = false }
19
+ }, [path])
20
+
21
+ const name = String(path).split(/[\\/]/).pop() || path
22
+ const good = meta !== null && meta !== undefined && meta.ok === true && meta.exists === true
23
+ const isDir = good && meta.type === 'directory'
24
+ const icon = isDir ? '📁' : iconFor(name)
25
+ const sub = meta === null ? '…' : (!good ? '不可用' : (isDir ? '文件夹' : formatSize(meta.size)))
26
+
27
+ return React.createElement('button', {
28
+ type: 'button',
29
+ className: 'wbd-card',
30
+ title: path,
31
+ onClick: () => { if (typeof openFile === 'function') { try { openFile(path) } catch { /* ignore */ } } },
32
+ },
33
+ React.createElement('span', { className: 'wbd-card-icon' }, icon),
34
+ React.createElement('span', { className: 'wbd-card-name' }, name),
35
+ React.createElement('span', { className: 'wbd-card-sub' }, sub),
36
+ )
37
+ }
38
+
39
+ return function FileCards(props: { matched: { refs: string[] }; openFile?: (path: string) => void }) {
40
+ const matched = props.matched
41
+ if (matched === null || matched === undefined || !Array.isArray(matched.refs) || matched.refs.length === 0) return null
42
+ return React.createElement('div', { className: 'wbd-cards' },
43
+ React.createElement('span', { className: 'wbd-cards-label' }, '📎 消息引用的文件'),
44
+ matched.refs.map((path, i) => React.createElement(FileCard, { key: String(path) + ':' + i, path, openFile: props.openFile })),
45
+ )
46
+ }
47
+ }