dsh-life-workbench 1.0.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 +97 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1232 -0
- package/lib/index.js +274 -0
- package/package.json +60 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-life-workbench —— Host 半边。
|
|
3
|
+
*
|
|
4
|
+
* 职责:工作台数据的唯一权威(结构归一 / 每日事项跨天归档 / 落盘),
|
|
5
|
+
* 并通过 webServer 暴露 4 个同源 JSON 端点供浏览器半边调用。
|
|
6
|
+
*
|
|
7
|
+
* 数据位置:`$DSH_HOME/life-workbench/`(DSH_HOME 未设时回退 `~/.dsh`)。
|
|
8
|
+
* 正式插件的 Host 半边是普通 Node 模块,可直接使用 node:fs —— 不需要动态
|
|
9
|
+
* 插件那套 `ctx.fs` + `sandboxPolicy` 绕行(沙箱约束的是 agent 的工具调用,
|
|
10
|
+
* 不是宿主插件代码)。
|
|
11
|
+
*
|
|
12
|
+
* 为什么用 HTTP 而不是包内 RPC:动态 Cordis 插件有 harness.handle + host.call,
|
|
13
|
+
* 正式插件没有这层,浏览器半边只能走同源 HTTP。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
|
|
20
|
+
const ROUTE_PREFIX = '/life-workbench'
|
|
21
|
+
const HISTORY_LIMIT = 90
|
|
22
|
+
const STATE_FILE = 'state.json'
|
|
23
|
+
const BLOB_PREFIX = 'blob-'
|
|
24
|
+
|
|
25
|
+
/** 本插件只依赖宿主 HTTP 载体,其余自给自足。 */
|
|
26
|
+
export const inject = ['webServer']
|
|
27
|
+
|
|
28
|
+
/* ------------------------------ 路径 ------------------------------ */
|
|
29
|
+
|
|
30
|
+
function dshHome() {
|
|
31
|
+
const fromEnv = process.env.DSH_HOME
|
|
32
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv
|
|
33
|
+
return path.join(os.homedir(), '.dsh')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 本插件的数据目录(与 dsh-task-board 的 `~/.dsh/task-board/` 同级)。 */
|
|
37
|
+
export function dataDir() {
|
|
38
|
+
return path.join(dshHome(), 'life-workbench')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const statePath = () => path.join(dataDir(), STATE_FILE)
|
|
42
|
+
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '')
|
|
43
|
+
const blobPath = (id) => path.join(dataDir(), BLOB_PREFIX + sanitizeId(id) + '.b64')
|
|
44
|
+
|
|
45
|
+
/* ------------------------------ 工具 ------------------------------ */
|
|
46
|
+
|
|
47
|
+
const pad = (n) => (String(n).length < 2 ? '0' + String(n) : String(n))
|
|
48
|
+
const stamp = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
49
|
+
const asArray = (value) => (Array.isArray(value) ? value : [])
|
|
50
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
51
|
+
const messageOf = (error) => String(error !== null && error !== undefined && error.message ? error.message : error)
|
|
52
|
+
|
|
53
|
+
function defaultBoards() {
|
|
54
|
+
return [
|
|
55
|
+
{ id: 'work', title: '工作', cards: [] },
|
|
56
|
+
{ id: 'life', title: '生活', cards: [] },
|
|
57
|
+
{ id: 'daily', title: '每日事项', kind: 'daily', cards: [] },
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function trimHistory(history) {
|
|
62
|
+
const keys = Object.keys(history).sort()
|
|
63
|
+
if (keys.length <= HISTORY_LIMIT) return history
|
|
64
|
+
const keep = {}
|
|
65
|
+
keys.slice(keys.length - HISTORY_LIMIT).forEach((key) => { keep[key] = history[key] })
|
|
66
|
+
return keep
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 补齐结构;「每日事项」跨天时先把当天逐条明细归档进 history,再重置勾选。
|
|
71
|
+
*/
|
|
72
|
+
function normalize(raw) {
|
|
73
|
+
const today = stamp(new Date())
|
|
74
|
+
const source = isObject(raw) ? raw : {}
|
|
75
|
+
const boards = asArray(source.boards)
|
|
76
|
+
const next = {
|
|
77
|
+
version: 6,
|
|
78
|
+
dailyStamp: typeof source.dailyStamp === 'string' ? source.dailyStamp : '',
|
|
79
|
+
boards: boards.length > 0 ? boards : defaultBoards(),
|
|
80
|
+
files: asArray(source.files),
|
|
81
|
+
notes: asArray(source.notes),
|
|
82
|
+
schedule: asArray(source.schedule),
|
|
83
|
+
history: isObject(source.history) ? source.history : {},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rolled = next.dailyStamp !== today
|
|
87
|
+
if (rolled) {
|
|
88
|
+
if (next.dailyStamp !== '') {
|
|
89
|
+
let dailyDone = 0
|
|
90
|
+
let dailyTotal = 0
|
|
91
|
+
const items = []
|
|
92
|
+
next.boards.forEach((board) => {
|
|
93
|
+
if (!isObject(board) || board.kind !== 'daily') return
|
|
94
|
+
asArray(board.cards).forEach((card) => {
|
|
95
|
+
if (!isObject(card)) return
|
|
96
|
+
const done = card.done === true
|
|
97
|
+
dailyTotal += 1
|
|
98
|
+
if (done) dailyDone += 1
|
|
99
|
+
items.push({ title: String(card.title || ''), done })
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
next.history[next.dailyStamp] = { dailyDone, dailyTotal, items }
|
|
103
|
+
}
|
|
104
|
+
next.boards = next.boards.map((board) => {
|
|
105
|
+
if (!isObject(board) || board.kind !== 'daily') return board
|
|
106
|
+
return {
|
|
107
|
+
...board,
|
|
108
|
+
cards: asArray(board.cards).map((card) => {
|
|
109
|
+
if (!isObject(card) || card.done !== true) return card
|
|
110
|
+
return { ...card, done: false, doneAt: null }
|
|
111
|
+
}),
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
next.dailyStamp = today
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
next.history = trimHistory(next.history)
|
|
118
|
+
return { state: next, rolled, today }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* --------------------------- 文件读写 --------------------------- */
|
|
122
|
+
|
|
123
|
+
function readState() {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(fs.readFileSync(statePath(), 'utf8'))
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error?.code !== 'ENOENT') {
|
|
128
|
+
console.error('[life-workbench] 读取状态失败,改用默认值', error)
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 先写临时文件再 rename,避免半截文件覆盖掉好数据。 */
|
|
135
|
+
function writeFileAtomic(target, content) {
|
|
136
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
137
|
+
const tmp = `${target}.${process.pid}.tmp`
|
|
138
|
+
fs.writeFileSync(tmp, content, 'utf8')
|
|
139
|
+
fs.renameSync(tmp, target)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeState(state) {
|
|
143
|
+
writeFileAtomic(statePath(), JSON.stringify(state, null, 2))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ---------------------------- 业务处理 ---------------------------- */
|
|
147
|
+
|
|
148
|
+
async function handleLoad() {
|
|
149
|
+
const raw = readState()
|
|
150
|
+
const result = normalize(raw)
|
|
151
|
+
if (raw === null || result.rolled) {
|
|
152
|
+
try {
|
|
153
|
+
writeState(result.state)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error('[life-workbench] 初始化写入失败', error)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
state: result.state,
|
|
161
|
+
today: result.today,
|
|
162
|
+
rolled: result.rolled,
|
|
163
|
+
path: statePath(),
|
|
164
|
+
storage: 'dsh-home',
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function handleSave(args) {
|
|
169
|
+
const incoming = isObject(args?.state) ? args.state : null
|
|
170
|
+
if (incoming === null) return { ok: false, error: '缺少 state' }
|
|
171
|
+
|
|
172
|
+
// history 由 Host 权威维护:以磁盘已有为准再合并,避免旧快照覆盖当天归档。
|
|
173
|
+
const disk = isObject(readState()?.history) ? readState().history : {}
|
|
174
|
+
const merged = {
|
|
175
|
+
...incoming,
|
|
176
|
+
history: { ...disk, ...(isObject(incoming.history) ? incoming.history : {}) },
|
|
177
|
+
}
|
|
178
|
+
const result = normalize(merged)
|
|
179
|
+
try {
|
|
180
|
+
writeState(result.state)
|
|
181
|
+
return { ok: true, today: result.today, rolled: result.rolled, storage: 'dsh-home' }
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return { ok: false, error: messageOf(error) }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function handleUpload(args) {
|
|
188
|
+
const name = typeof args?.name === 'string' && args.name !== '' ? args.name : '未命名'
|
|
189
|
+
const mime = typeof args?.mime === 'string' ? args.mime : ''
|
|
190
|
+
const base64 = typeof args?.base64 === 'string' ? args.base64 : ''
|
|
191
|
+
const size = typeof args?.size === 'number' ? args.size : 0
|
|
192
|
+
if (base64 === '') return { ok: false, error: '文件内容为空' }
|
|
193
|
+
|
|
194
|
+
const id = 'f' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1679616).toString(36)
|
|
195
|
+
try {
|
|
196
|
+
writeFileAtomic(blobPath(id), base64)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return { ok: false, error: '写入失败:' + messageOf(error) }
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
ok: true,
|
|
202
|
+
file: {
|
|
203
|
+
id,
|
|
204
|
+
name,
|
|
205
|
+
mime,
|
|
206
|
+
size,
|
|
207
|
+
kind: mime.indexOf('image/') === 0 ? 'image' : 'file',
|
|
208
|
+
addedAt: new Date().toISOString(),
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function handleReadFile(args) {
|
|
214
|
+
const id = typeof args?.id === 'string' ? args.id : ''
|
|
215
|
+
if (id === '') return { ok: false, error: '缺少 id' }
|
|
216
|
+
try {
|
|
217
|
+
return { ok: true, base64: fs.readFileSync(blobPath(id), 'utf8') }
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error?.code === 'ENOENT') return { ok: false, error: '文件不存在' }
|
|
220
|
+
return { ok: false, error: messageOf(error) }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* --------------------------- HTTP 路由 --------------------------- */
|
|
225
|
+
|
|
226
|
+
function send(res, status, payload) {
|
|
227
|
+
res.writeHead(status, {
|
|
228
|
+
'content-type': 'application/json; charset=utf-8',
|
|
229
|
+
'cache-control': 'no-store',
|
|
230
|
+
})
|
|
231
|
+
res.end(JSON.stringify(payload))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function readJsonBody(req) {
|
|
235
|
+
const chunks = []
|
|
236
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
237
|
+
if (chunks.length === 0) return {}
|
|
238
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
239
|
+
return text === '' ? {} : JSON.parse(text)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function apply(ctx) {
|
|
243
|
+
const webServer = ctx.get('webServer')
|
|
244
|
+
if (webServer === undefined) {
|
|
245
|
+
console.error('[life-workbench] 缺少 webServer 服务,Host 半边未激活')
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const disposeRoute = webServer.register({
|
|
250
|
+
kind: 'prefix',
|
|
251
|
+
path: ROUTE_PREFIX,
|
|
252
|
+
handler: async (req, res) => {
|
|
253
|
+
const url = req.url ?? ''
|
|
254
|
+
const action = url.slice(ROUTE_PREFIX.length).split('?')[0].replace(/^\//, '')
|
|
255
|
+
if (req.method !== 'POST') {
|
|
256
|
+
send(res, 405, { ok: false, error: '只接受 POST' })
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const args = await readJsonBody(req)
|
|
261
|
+
if (action === 'load') return send(res, 200, await handleLoad())
|
|
262
|
+
if (action === 'save') return send(res, 200, await handleSave(args))
|
|
263
|
+
if (action === 'upload') return send(res, 200, await handleUpload(args))
|
|
264
|
+
if (action === 'readFile') return send(res, 200, await handleReadFile(args))
|
|
265
|
+
return send(res, 404, { ok: false, error: '未知端点:' + action })
|
|
266
|
+
} catch (error) {
|
|
267
|
+
// 统一以 200 + ok:false 返回业务错误,客户端只处理一种形状
|
|
268
|
+
return send(res, 200, { ok: false, error: messageOf(error) })
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
ctx.effect(() => disposeRoute)
|
|
274
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-life-workbench",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH 个人工作台:总览 / 看板 / 资料三视图,玻璃拟态视觉,Host 权威持久化。Personal workbench plugin for DSH Web.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"dsh",
|
|
20
|
+
"dsh-plugin",
|
|
21
|
+
"deepseek-harness",
|
|
22
|
+
"workbench",
|
|
23
|
+
"kanban",
|
|
24
|
+
"todo",
|
|
25
|
+
"dashboard"
|
|
26
|
+
],
|
|
27
|
+
"author": "chendb",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/meihaoyidian/life-workbench.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/meihaoyidian/life-workbench#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/meihaoyidian/life-workbench/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"dsh": {
|
|
41
|
+
"engines": {
|
|
42
|
+
"dsh": ">=0.1.5-rc.1"
|
|
43
|
+
},
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"inject": [],
|
|
49
|
+
"platform": "web"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"react": "^18.2.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"react": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|