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/LICENSE +21 -0
- package/README.md +184 -0
- package/cordis.patch.yml +24 -0
- package/lib/client.js +984 -0
- package/lib/index.js +348 -0
- package/package.json +88 -0
- package/src/client/app.ts +130 -0
- package/src/client/at-source.ts +57 -0
- package/src/client/components/file-cards.tsx +47 -0
- package/src/client/components/overlay.tsx +36 -0
- package/src/client/components/pick-button.tsx +55 -0
- package/src/client/css.ts +32 -0
- package/src/client/definitions.ts +83 -0
- package/src/client/index.ts +15 -0
- package/src/client/lib/bus.ts +34 -0
- package/src/client/lib/drop.ts +235 -0
- package/src/client/lib/icons.ts +26 -0
- package/src/client/lib/insert.ts +107 -0
- package/src/client/lib/transfer.ts +113 -0
- package/src/client/types.ts +60 -0
- package/src/host/index.ts +253 -0
|
@@ -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
|
+
}
|