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.
@@ -0,0 +1,113 @@
1
+ import type { DropsItem, DropsStat, TreeFile, UploadJob } from '../types'
2
+
3
+ /** 引用文本格式:无空格直接 @path;含空格/引号用 @"path";目录保留尾斜杠 */
4
+ export function mentionFor(path: string, isDir: boolean): string {
5
+ let p = String(path)
6
+ if (isDir) p = p.replace(/[\\/]+$/, '') + '/'
7
+ if (/\s|"/.test(p)) return '@"' + p + '"'
8
+ return '@' + p
9
+ }
10
+
11
+ /** 从用户消息文本中提取 @ 文件引用(消息发送后的序列化形式) */
12
+ export function extractRefs(content: ReadonlyArray<{ type: string; text?: string }>): string[] {
13
+ const out: string[] = []
14
+ for (const b of content) {
15
+ if (b !== null && b !== undefined && b.type === 'text' && typeof b.text === 'string') {
16
+ const re = /@"([^"]+)"|@([^\s"@]+)/g
17
+ let m: RegExpExecArray | null
18
+ while ((m = re.exec(b.text)) !== null) {
19
+ const p = (m[1] !== undefined ? m[1] : m[2]).trim()
20
+ if (p !== '' && !out.includes(p)) out.push(p)
21
+ }
22
+ }
23
+ }
24
+ return out.slice(0, 40)
25
+ }
26
+
27
+ /**
28
+ * 后台上传任务队列:逐个落盘(目录任务先遍历),不阻塞输入。
29
+ * 交付物版本走 webServer 二进制路由(/workbuddy-drops),无 base64、无 JSON 体积上限。
30
+ * @returns 成功数量与失败清单(文件名 + 原因)
31
+ */
32
+ export async function runUploadJobs(jobs: UploadJob[], batch: string, maxFileBytes = 256 * 1048576): Promise<{ ok: number; failed: string[] }> {
33
+ let ok = 0
34
+ const failed: string[] = []
35
+ for (const j of jobs) {
36
+ const files: TreeFile[] = j.kind === 'dir' ? await walkEntry(j.entry, '') : [{ rel: j.rel, name: j.name, size: j.file.size, file: j.file }]
37
+ for (const f of files) {
38
+ if (f.file.size > maxFileBytes) { failed.push(f.name + '(超过大小上限)'); continue }
39
+ const url = '/workbuddy-drops/save?batch=' + encodeURIComponent(batch) + '&rel=' + encodeURIComponent(f.rel)
40
+ const res = await fetch(url, { method: 'POST', body: f.file })
41
+ if (res.ok) {
42
+ const body = await res.json() as { ok?: boolean; error?: string }
43
+ if (body.ok === true) { ok += 1; continue }
44
+ failed.push(f.name + '(' + (body.error ?? '写入失败') + ')')
45
+ } else {
46
+ failed.push(f.name + '(HTTP ' + res.status + ')')
47
+ }
48
+ }
49
+ }
50
+ return { ok, failed }
51
+ }
52
+
53
+ /** 缓存根目录(~/.dsh-drops) */
54
+ export async function dropsHome(): Promise<string | null> {
55
+ try {
56
+ const res = await fetch('/workbuddy-drops/home')
57
+ const body = await res.json() as { ok?: boolean; root?: string }
58
+ if (body.ok === true && typeof body.root === 'string') return body.root
59
+ return null
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ export function walkEntry(entry: FileSystemEntry, rel: string): Promise<TreeFile[]> {
66
+ return new Promise((resolve2) => {
67
+ if (entry.isFile) {
68
+ const fe = entry as FileSystemFileEntry
69
+ fe.file(
70
+ (f) => resolve2([{ rel: rel === '' ? entry.name : rel + '/' + entry.name, name: entry.name, size: f.size, file: f }]),
71
+ () => resolve2([]),
72
+ )
73
+ return
74
+ }
75
+ if (entry.isDirectory) {
76
+ const de = entry as FileSystemDirectoryEntry
77
+ const reader = de.createReader()
78
+ const found: FileSystemEntry[] = []
79
+ const readBatch = () => reader.readEntries((ents) => {
80
+ if (ents.length === 0) {
81
+ Promise.all(found.map((e) => walkEntry(e, rel === '' ? entry.name : rel + '/' + entry.name)))
82
+ .then((rs) => resolve2(rs.flat()))
83
+ .catch(() => resolve2([]))
84
+ return
85
+ }
86
+ found.push(...ents)
87
+ readBatch()
88
+ }, () => resolve2([]))
89
+ readBatch()
90
+ return
91
+ }
92
+ resolve2([])
93
+ })
94
+ }
95
+
96
+ export async function dropsList(query: string): Promise<DropsItem[]> {
97
+ try {
98
+ const res = await fetch('/workbuddy-drops/list?query=' + encodeURIComponent(query))
99
+ const body = await res.json() as { items?: DropsItem[] }
100
+ return body.items ?? []
101
+ } catch {
102
+ return []
103
+ }
104
+ }
105
+
106
+ export async function dropsStat(path: string): Promise<DropsStat> {
107
+ try {
108
+ const res = await fetch('/workbuddy-drops/stat?path=' + encodeURIComponent(path))
109
+ return await res.json() as DropsStat
110
+ } catch {
111
+ return { ok: false, path }
112
+ }
113
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Client 半侧共享类型。
3
+ * 线上运行期这些类型来自宿主提供的 peer 依赖包;此处仅声明插件内部使用的形状。
4
+ */
5
+
6
+ /** 一次拖拽/粘贴/选择后待插入输入框的引用项 */
7
+ export interface InsertItem {
8
+ label: string
9
+ reference: {
10
+ source: 'workbuddy'
11
+ ref: string
12
+ label: string
13
+ appearance: 'file' | 'folder'
14
+ clipboardText: string
15
+ }
16
+ }
17
+
18
+ /** 目录树遍历得到的文件条目 */
19
+ export interface TreeFile {
20
+ /** 相对路径('/' 分隔,含顶层目录名;松散文件时就是文件名) */
21
+ rel: string
22
+ name: string
23
+ size: number
24
+ file: File
25
+ }
26
+
27
+ /** 后台上传任务:松散文件,或待遍历的目录 entry */
28
+ export type UploadJob = {
29
+ kind: 'file'
30
+ file: File
31
+ rel: string
32
+ name: string
33
+ } | {
34
+ kind: 'dir'
35
+ entry: FileSystemEntry
36
+ }
37
+
38
+ /** drops.stat / drops.list 等 RPC 的返回形状 */
39
+ export interface DropsStat {
40
+ ok: boolean
41
+ exists?: boolean
42
+ path: string
43
+ type?: string
44
+ size?: number | null
45
+ error?: string
46
+ }
47
+
48
+ export interface DropsItem {
49
+ name: string
50
+ path: string
51
+ type: string
52
+ size: number | null
53
+ }
54
+
55
+ /** 拖拽/粘贴总线状态 */
56
+ export interface BusState {
57
+ active: boolean
58
+ count: number
59
+ toast: { text: string; level: 'info' | 'error' } | null
60
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * dsh-workbuddy-files · Host 半侧
3
+ *
4
+ * 职责:
5
+ * 1. 文件落地缓存 `~/.dsh-drops/`(保留拖入目录树结构),通过 webServer 路由
6
+ * `/workbuddy-drops` 接收浏览器 fetch 的二进制 POST(无需 base64,支持大文件);
7
+ * 2. 注册 `read_document` 模型工具:解析 `@"path"` / `@path` 引用并返回内容。
8
+ *
9
+ * 真实插件包的 Host 半侧运行在 DSH 的 Node 进程里(非沙箱),可以直接使用
10
+ * node:fs —— 这也是 dsh-pet 等第三方插件的标准写法(参考其 src/host/index.ts)。
11
+ */
12
+ import { homedir } from 'node:os'
13
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
14
+ import { dirname, join, normalize, resolve, sep } from 'node:path'
15
+ import { Context } from '@deepseek-ai/cordis'
16
+ import { defineTool } from '@deepseek-ai/dsh-tools'
17
+
18
+ /** 插件行 id(与 cordis.patch.yml 的 id 一致) */
19
+ export const name = 'workbuddy-files'
20
+ /** 硬依赖:Web 服务器路由注册表 */
21
+ export const inject = ['webServer']
22
+
23
+ export interface WorkbuddyConfig {
24
+ /** 拖拽/粘贴文件的落地目录(默认 '~/.dsh-drops',支持 '~' 前缀) */
25
+ dropsDir?: string
26
+ /** 单文件大小上限(字节,默认 256MB;0 = 不限) */
27
+ maxFileBytes?: number
28
+ }
29
+
30
+ interface ResolvedConfig {
31
+ dropsDir: string
32
+ maxFileBytes: number
33
+ }
34
+
35
+ function resolveConfig(config: WorkbuddyConfig = {}): ResolvedConfig {
36
+ return {
37
+ dropsDir: config.dropsDir ?? '~/.dsh-drops',
38
+ maxFileBytes: config.maxFileBytes ?? 256 * 1048576,
39
+ }
40
+ }
41
+
42
+ function expandHome(dir: string): string {
43
+ if (dir === '~' || dir.startsWith('~/')) return join(homedir(), dir.slice(1))
44
+ return dir
45
+ }
46
+
47
+ /** 规范化相对路径:拒绝 `..`、绝对路径与控制字符(防目录穿越) */
48
+ function normRel(rel: string): string | null {
49
+ const parts = String(rel).replace(/\\/g, '/').split('/')
50
+ const out: string[] = []
51
+ for (const p of parts) {
52
+ if (p === '' || p === '.') continue
53
+ if (p === '..') return null
54
+ // eslint-disable-next-line no-control-regex
55
+ if (/[\u0000-\u001f"\r\n]/.test(p)) return null
56
+ out.push(p)
57
+ }
58
+ return out.join('/')
59
+ }
60
+
61
+ /** 校验最终路径仍位于 drops 根目录内(第二道防线) */
62
+ function resolveDrops(root: string, rel: string): string | undefined {
63
+ const candidate = normalize(join(root, rel))
64
+ const rootWithSep = root.endsWith(sep) ? root : root + sep
65
+ if (candidate !== root && !candidate.startsWith(rootWithSep)) return undefined
66
+ return candidate
67
+ }
68
+
69
+ function sendJson(res: any, status: number, obj: unknown): void {
70
+ const body = JSON.stringify(obj)
71
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) })
72
+ res.end(body)
73
+ }
74
+
75
+ /** 收集请求体原始字节(流式,不设上限;上限检查在写盘前按 maxFileBytes 执行) */
76
+ function readBody(req: any): Promise<Buffer> {
77
+ return new Promise((resolve2, reject) => {
78
+ const chunks: Buffer[] = []
79
+ let total = 0
80
+ req.on('data', (c: Buffer) => { chunks.push(c); total += c.length })
81
+ req.on('end', () => resolve2(Buffer.concat(chunks, total)))
82
+ req.on('error', reject)
83
+ })
84
+ }
85
+
86
+ /** UTF-8 可解码性 / 二进制探测(NUL 字节即二进制) */
87
+ function looksBinary(buf: Buffer): boolean {
88
+ if (buf.includes(0)) return true
89
+ try {
90
+ new TextDecoder('utf-8', { fatal: true }).decode(buf)
91
+ return false
92
+ } catch {
93
+ return true
94
+ }
95
+ }
96
+
97
+ export function apply(ctx: Context, config: WorkbuddyConfig = {}) {
98
+ const resolved = resolveConfig(config)
99
+ const dropsDir = resolve(expandHome(resolved.dropsDir))
100
+ const webServer = (ctx as unknown as {
101
+ webServer: {
102
+ register(route: { kind: 'prefix'; path: string; handler: (req: any, res: any) => void | Promise<void> }): () => void
103
+ }
104
+ }).webServer
105
+
106
+ // ---------------- /workbuddy-drops 路由 ----------------
107
+ ctx.effect(() => webServer.register({
108
+ kind: 'prefix',
109
+ path: '/workbuddy-drops',
110
+ handler: async (req: any, res: any) => {
111
+ const url = new URL(req.url ?? '/', 'http://localhost')
112
+ const action = url.pathname.slice('/workbuddy-drops'.length).replace(/^\/+/, '')
113
+ try {
114
+ if (action === 'home') {
115
+ sendJson(res, 200, { ok: true, root: dropsDir })
116
+ return
117
+ }
118
+ if (action === 'stat') {
119
+ const path = url.searchParams.get('path') ?? ''
120
+ if (path === '') { sendJson(res, 200, { ok: true, exists: false, path }); return }
121
+ const info = await stat(path)
122
+ sendJson(res, 200, { ok: true, exists: true, path, type: info.isDirectory() ? 'directory' : info.isFile() ? 'file' : 'other', size: info.isFile() ? info.size : null })
123
+ return
124
+ }
125
+ if (action === 'list') {
126
+ const q = (url.searchParams.get('query') ?? '').toLowerCase()
127
+ const items: Array<{ name: string; path: string; type: string; size: number | null }> = []
128
+ const add = (p: string, name: string, type: string, size?: number) => {
129
+ if (items.length >= 200) return
130
+ if (q === '' || p.toLowerCase().includes(q) || name.toLowerCase().includes(q)) items.push({ name, path: p, type, size: size ?? null })
131
+ }
132
+ const rootInfo = await stat(dropsDir).catch(() => null)
133
+ if (rootInfo === null) { sendJson(res, 200, { ok: true, items: [] }); return }
134
+ const batches = await readdir(dropsDir, { withFileTypes: true })
135
+ for (const b of batches) {
136
+ if (items.length >= 200) break
137
+ if (b.name === '.tmp' || b.name.charAt(0) === '.') continue
138
+ const bp = join(dropsDir, b.name)
139
+ if (b.isFile()) {
140
+ const s = await stat(bp).catch(() => null)
141
+ add(bp, b.name, 'file', s?.size)
142
+ continue
143
+ }
144
+ if (!b.isDirectory()) continue
145
+ const children = await readdir(bp, { withFileTypes: true }).catch(() => [])
146
+ for (const c of children) {
147
+ if (items.length >= 200) break
148
+ const cp = join(bp, c.name)
149
+ const isDir = c.isDirectory()
150
+ const s = isDir ? null : await stat(cp).catch(() => null)
151
+ add(cp, c.name, isDir ? 'directory' : 'file', s?.size ?? undefined)
152
+ if (isDir) {
153
+ const sub = await readdir(cp, { withFileTypes: true }).catch(() => [])
154
+ for (const s2 of sub) {
155
+ if (items.length >= 200) break
156
+ const s2p = join(cp, s2.name)
157
+ const s2dir = s2.isDirectory()
158
+ const st2 = s2dir ? null : await stat(s2p).catch(() => null)
159
+ add(s2p, s2.name, s2dir ? 'directory' : 'file', st2?.size ?? undefined)
160
+ }
161
+ }
162
+ }
163
+ }
164
+ sendJson(res, 200, { ok: true, items })
165
+ return
166
+ }
167
+ if (action === 'save' && req.method === 'POST') {
168
+ const rel = normRel(url.searchParams.get('rel') ?? '')
169
+ const batch = normRel(url.searchParams.get('batch') ?? '') || 'misc'
170
+ if (rel === null || rel === '') { sendJson(res, 400, { ok: false, error: '目标路径非法' }); return }
171
+ const dst = resolveDrops(dropsDir, batch + '/' + rel)
172
+ if (dst === undefined) { sendJson(res, 400, { ok: false, error: '目标路径越界' }); return }
173
+ const body = await readBody(req)
174
+ if (resolved.maxFileBytes > 0 && body.length > resolved.maxFileBytes) {
175
+ sendJson(res, 413, { ok: false, error: '文件超过大小上限 ' + Math.floor(resolved.maxFileBytes / 1048576) + 'MB' })
176
+ return
177
+ }
178
+ await mkdir(dirname(dst), { recursive: true })
179
+ await writeFile(dst, body)
180
+ sendJson(res, 200, { ok: true, path: dst, rel: batch + '/' + rel, root: dropsDir })
181
+ return
182
+ }
183
+ sendJson(res, 404, { ok: false, error: 'unknown action: ' + action })
184
+ } catch (err) {
185
+ sendJson(res, 500, { ok: false, error: String((err as Error)?.message ?? err) })
186
+ }
187
+ },
188
+ }), 'workbuddy: /workbuddy-drops route')
189
+
190
+ // ---------------- read_document 模型工具 ----------------
191
+ const readDocumentTool = defineTool({
192
+ name: 'read_document',
193
+ description:
194
+ '解析并读取用户消息中拖拽/粘贴/@ 产生的文件引用(支持 @"C:\\path\\file.pdf"、@path 或裸绝对路径)。' +
195
+ '文本类文件(代码、文档、数据)直接返回内容;文件夹引用返回目录树;图片、二进制与超大文件返回元数据与读取建议。',
196
+ parameters: {
197
+ reference: { type: 'string', required: true, description: '文件引用:消息中的 @"..." 或 @... 令牌,或直接给出绝对路径' },
198
+ max_chars: { type: 'number', description: '文本内容返回上限(默认 60000 字符)' },
199
+ },
200
+ output: {
201
+ schema: { type: 'object', additionalProperties: true } as any,
202
+ render: (_args: unknown, value: unknown) => [{ type: 'text', text: JSON.stringify(value) }],
203
+ },
204
+ execute: async (args: { reference: string; max_chars?: number }, _exec: unknown): Promise<any> => {
205
+ const maxChars = typeof args.max_chars === 'number' && args.max_chars > 0 ? Math.floor(args.max_chars) : 60000
206
+ const raw = String(args.reference ?? '').trim()
207
+ if (raw === '') return { ok: false, error: 'reference 为空' }
208
+ if (raw.startsWith('dsh-drop://')) {
209
+ return { ok: false, error: '该引用是浏览器端零上传直引(FS Access API 句柄),发送消息时已物化到本地缓存。请用消息中的真实路径重新读取。' }
210
+ }
211
+ let path = raw
212
+ if (path.charAt(0) === '@') path = path.slice(1).trim()
213
+ if (path.length >= 2 && path.charAt(0) === '"' && path.charAt(path.length - 1) === '"') path = path.slice(1, -1)
214
+ path = path.replace(/\/+$/, '')
215
+ try {
216
+ const info = await stat(path)
217
+ const base = path.split(/[\\/]/).pop()
218
+ if (info.isDirectory()) {
219
+ const entries = await readdir(path, { withFileTypes: true })
220
+ const tree = entries.slice(0, 300).map(async (e) => {
221
+ const st = e.isFile() ? await stat(join(path, e.name)).catch(() => null) : null
222
+ return { name: e.name, type: e.isDirectory() ? 'directory' : 'file', size: st?.size ?? null }
223
+ })
224
+ return { ok: true, path, name: base, kind: 'directory', entries: await Promise.all(tree), truncated: entries.length > 300 }
225
+ }
226
+ if (!info.isFile()) return { ok: false, error: '目标不是普通文件: ' + path }
227
+ const size = info.size
228
+ if (/\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i.test(base ?? '')) {
229
+ return { ok: true, path, name: base, kind: 'image', size, hint: '图片文件:请使用 read_image 工具以该路径直接查看。' }
230
+ }
231
+ if (size > 1500000) {
232
+ return { ok: true, path, name: base, kind: 'large', size, hint: '文件过大,未内联内容;可请用户拆分,或用支持分块读取的工具处理。' }
233
+ }
234
+ const buf = await readFile(path)
235
+ if (looksBinary(buf)) {
236
+ return { ok: true, path, name: base, kind: 'binary', size, hint: '二进制文件。若为常见格式,可询问用户或用宿主工具转换后读取。' }
237
+ }
238
+ let content = buf.toString('utf8')
239
+ const truncated = content.length > maxChars
240
+ if (truncated) content = content.slice(0, maxChars)
241
+ return { ok: true, path, name: base, kind: 'text', size, content, truncated }
242
+ } catch (err) {
243
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, error: '路径不存在: ' + path }
244
+ return { ok: false, error: String((err as Error)?.message ?? err) }
245
+ }
246
+ },
247
+ presentCall: (args: { reference?: string }) => ({ card: 'generic', title: '读取引用文件', kind: 'read', rawInput: String(args.reference ?? '') }),
248
+ })
249
+ const tools = ctx.get('tools')
250
+ ctx.effect(() => (tools !== undefined ? tools.register(readDocumentTool) : (() => {})), 'workbuddy: read_document tool')
251
+
252
+ console.log('[workbuddy-files] host 就绪:drops 缓存 ' + dropsDir + '(/workbuddy-drops 路由)+ read_document 工具')
253
+ }