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/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
+ }