@simonyea/holysheep-cli 1.7.135 → 2.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/README.md +2 -1
- package/package.json +2 -2
- package/src/tools/droid.js +4 -2
- package/src/utils/which.js +28 -2
- package/src/webui/index.html +1329 -584
- package/src/webui/server.js +269 -32
- package/src/webui/workspace-runtime.js +288 -0
- package/src/webui/workspace-store.js +325 -0
- package/tests/workspace-store.test.js +57 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const crypto = require('crypto')
|
|
6
|
+
const { CONFIG_DIR } = require('../utils/config')
|
|
7
|
+
|
|
8
|
+
const STATE_FILE = path.join(CONFIG_DIR, 'workspace-state.json')
|
|
9
|
+
const STATE_VERSION = 1
|
|
10
|
+
|
|
11
|
+
function now() {
|
|
12
|
+
return new Date().toISOString()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createDefaultState() {
|
|
16
|
+
return {
|
|
17
|
+
version: STATE_VERSION,
|
|
18
|
+
updatedAt: now(),
|
|
19
|
+
holySheepApi: {
|
|
20
|
+
apiKey: '',
|
|
21
|
+
baseUrl: '',
|
|
22
|
+
model: '',
|
|
23
|
+
},
|
|
24
|
+
conversations: [],
|
|
25
|
+
scheduledTasks: [],
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureDir() {
|
|
30
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clone(value) {
|
|
34
|
+
return JSON.parse(JSON.stringify(value))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeConversation(item = {}) {
|
|
38
|
+
const createdAt = item.createdAt || now()
|
|
39
|
+
const messages = Array.isArray(item.messages)
|
|
40
|
+
? item.messages.map((message) => ({
|
|
41
|
+
id: message.id || crypto.randomUUID(),
|
|
42
|
+
role: message.role === 'assistant' ? 'assistant' : 'user',
|
|
43
|
+
content: String(message.content || ''),
|
|
44
|
+
status: message.status || 'done',
|
|
45
|
+
createdAt: message.createdAt || createdAt,
|
|
46
|
+
meta: typeof message.meta === 'object' && message.meta ? message.meta : {},
|
|
47
|
+
}))
|
|
48
|
+
: []
|
|
49
|
+
|
|
50
|
+
const lastMessage = messages[messages.length - 1] || null
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
id: item.id || crypto.randomUUID().slice(0, 8),
|
|
54
|
+
title: String(item.title || 'New Conversation'),
|
|
55
|
+
toolId: String(item.toolId || 'codex'),
|
|
56
|
+
createdAt,
|
|
57
|
+
updatedAt: item.updatedAt || lastMessage?.createdAt || createdAt,
|
|
58
|
+
pinned: Boolean(item.pinned),
|
|
59
|
+
summary: String(item.summary || lastMessage?.content || '').slice(0, 240),
|
|
60
|
+
messages,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeTask(item = {}) {
|
|
65
|
+
return {
|
|
66
|
+
id: item.id || crypto.randomUUID().slice(0, 8),
|
|
67
|
+
title: String(item.title || 'Untitled Task'),
|
|
68
|
+
prompt: String(item.prompt || ''),
|
|
69
|
+
schedule: String(item.schedule || '1h'),
|
|
70
|
+
active: item.active !== false,
|
|
71
|
+
createdAt: item.createdAt || now(),
|
|
72
|
+
updatedAt: item.updatedAt || now(),
|
|
73
|
+
lastRunAt: item.lastRunAt || null,
|
|
74
|
+
lastStatus: item.lastStatus || 'idle',
|
|
75
|
+
lastResult: String(item.lastResult || ''),
|
|
76
|
+
conversationId: item.conversationId || null,
|
|
77
|
+
modelOverride: item.modelOverride ? String(item.modelOverride) : '',
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeState(raw = {}) {
|
|
82
|
+
const state = createDefaultState()
|
|
83
|
+
state.holySheepApi = {
|
|
84
|
+
...state.holySheepApi,
|
|
85
|
+
...(typeof raw.holySheepApi === 'object' && raw.holySheepApi ? raw.holySheepApi : {}),
|
|
86
|
+
}
|
|
87
|
+
state.conversations = Array.isArray(raw.conversations) ? raw.conversations.map(normalizeConversation) : []
|
|
88
|
+
state.scheduledTasks = Array.isArray(raw.scheduledTasks) ? raw.scheduledTasks.map(normalizeTask) : []
|
|
89
|
+
state.updatedAt = raw.updatedAt || now()
|
|
90
|
+
return state
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function loadState() {
|
|
94
|
+
ensureDir()
|
|
95
|
+
try {
|
|
96
|
+
if (!fs.existsSync(STATE_FILE)) {
|
|
97
|
+
return createDefaultState()
|
|
98
|
+
}
|
|
99
|
+
return normalizeState(JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')))
|
|
100
|
+
} catch {
|
|
101
|
+
return createDefaultState()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function saveState(next) {
|
|
106
|
+
ensureDir()
|
|
107
|
+
const normalized = normalizeState(next)
|
|
108
|
+
normalized.updatedAt = now()
|
|
109
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(normalized, null, 2), 'utf8')
|
|
110
|
+
return normalized
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function updateState(mutator) {
|
|
114
|
+
const current = loadState()
|
|
115
|
+
const draft = clone(current)
|
|
116
|
+
const result = mutator(draft) || draft
|
|
117
|
+
return saveState(result)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sortConversations(items) {
|
|
121
|
+
return [...items].sort((a, b) => {
|
|
122
|
+
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
|
123
|
+
return Date.parse(b.updatedAt) - Date.parse(a.updatedAt)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function listConversations() {
|
|
128
|
+
return sortConversations(loadState().conversations).map((item) => ({
|
|
129
|
+
id: item.id,
|
|
130
|
+
title: item.title,
|
|
131
|
+
toolId: item.toolId,
|
|
132
|
+
pinned: item.pinned,
|
|
133
|
+
summary: item.summary,
|
|
134
|
+
createdAt: item.createdAt,
|
|
135
|
+
updatedAt: item.updatedAt,
|
|
136
|
+
messageCount: item.messages.length,
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getConversation(id) {
|
|
141
|
+
return loadState().conversations.find((item) => item.id === id) || null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function ensureConversation(id) {
|
|
145
|
+
const conversation = getConversation(id)
|
|
146
|
+
if (!conversation) throw new Error('Conversation not found')
|
|
147
|
+
return conversation
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function createConversation(payload = {}) {
|
|
151
|
+
const conversation = normalizeConversation({
|
|
152
|
+
title: payload.title || 'New Conversation',
|
|
153
|
+
toolId: payload.toolId || 'codex',
|
|
154
|
+
pinned: Boolean(payload.pinned),
|
|
155
|
+
})
|
|
156
|
+
updateState((state) => {
|
|
157
|
+
state.conversations.unshift(conversation)
|
|
158
|
+
})
|
|
159
|
+
return conversation
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function updateConversation(id, patch = {}) {
|
|
163
|
+
let updated = null
|
|
164
|
+
updateState((state) => {
|
|
165
|
+
const index = state.conversations.findIndex((item) => item.id === id)
|
|
166
|
+
if (index === -1) throw new Error('Conversation not found')
|
|
167
|
+
const current = state.conversations[index]
|
|
168
|
+
updated = normalizeConversation({
|
|
169
|
+
...current,
|
|
170
|
+
...patch,
|
|
171
|
+
messages: patch.messages || current.messages,
|
|
172
|
+
updatedAt: now(),
|
|
173
|
+
})
|
|
174
|
+
state.conversations[index] = updated
|
|
175
|
+
})
|
|
176
|
+
return updated
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function addMessage(conversationId, payload = {}) {
|
|
180
|
+
let updated = null
|
|
181
|
+
updateState((state) => {
|
|
182
|
+
const conversation = state.conversations.find((item) => item.id === conversationId)
|
|
183
|
+
if (!conversation) throw new Error('Conversation not found')
|
|
184
|
+
const message = {
|
|
185
|
+
id: payload.id || crypto.randomUUID(),
|
|
186
|
+
role: payload.role === 'assistant' ? 'assistant' : 'user',
|
|
187
|
+
content: String(payload.content || ''),
|
|
188
|
+
status: payload.status || 'done',
|
|
189
|
+
createdAt: payload.createdAt || now(),
|
|
190
|
+
meta: typeof payload.meta === 'object' && payload.meta ? payload.meta : {},
|
|
191
|
+
}
|
|
192
|
+
conversation.messages.push(message)
|
|
193
|
+
conversation.updatedAt = message.createdAt
|
|
194
|
+
if (!conversation.summary || message.role === 'assistant') {
|
|
195
|
+
conversation.summary = message.content.slice(0, 240)
|
|
196
|
+
}
|
|
197
|
+
updated = clone(message)
|
|
198
|
+
})
|
|
199
|
+
return updated
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function searchWorkspace(query) {
|
|
203
|
+
const needle = String(query || '').trim().toLowerCase()
|
|
204
|
+
if (!needle) return { conversations: [], tasks: [] }
|
|
205
|
+
|
|
206
|
+
const state = loadState()
|
|
207
|
+
const conversations = state.conversations
|
|
208
|
+
.filter((item) => {
|
|
209
|
+
if (item.title.toLowerCase().includes(needle)) return true
|
|
210
|
+
if (String(item.summary || '').toLowerCase().includes(needle)) return true
|
|
211
|
+
return item.messages.some((message) => String(message.content || '').toLowerCase().includes(needle))
|
|
212
|
+
})
|
|
213
|
+
.map((item) => ({
|
|
214
|
+
id: item.id,
|
|
215
|
+
title: item.title,
|
|
216
|
+
summary: item.summary,
|
|
217
|
+
updatedAt: item.updatedAt,
|
|
218
|
+
toolId: item.toolId,
|
|
219
|
+
}))
|
|
220
|
+
|
|
221
|
+
const tasks = state.scheduledTasks
|
|
222
|
+
.filter((item) => {
|
|
223
|
+
return item.title.toLowerCase().includes(needle) || item.prompt.toLowerCase().includes(needle)
|
|
224
|
+
})
|
|
225
|
+
.map((item) => ({
|
|
226
|
+
id: item.id,
|
|
227
|
+
title: item.title,
|
|
228
|
+
prompt: item.prompt.slice(0, 180),
|
|
229
|
+
schedule: item.schedule,
|
|
230
|
+
active: item.active,
|
|
231
|
+
updatedAt: item.updatedAt,
|
|
232
|
+
}))
|
|
233
|
+
|
|
234
|
+
return { conversations, tasks }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function listTasks() {
|
|
238
|
+
return [...loadState().scheduledTasks].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getTask(id) {
|
|
242
|
+
return loadState().scheduledTasks.find((item) => item.id === id) || null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function saveTask(payload = {}) {
|
|
246
|
+
let result = null
|
|
247
|
+
updateState((state) => {
|
|
248
|
+
if (payload.id) {
|
|
249
|
+
const index = state.scheduledTasks.findIndex((item) => item.id === payload.id)
|
|
250
|
+
if (index === -1) throw new Error('Task not found')
|
|
251
|
+
result = normalizeTask({
|
|
252
|
+
...state.scheduledTasks[index],
|
|
253
|
+
...payload,
|
|
254
|
+
updatedAt: now(),
|
|
255
|
+
})
|
|
256
|
+
state.scheduledTasks[index] = result
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
result = normalizeTask({
|
|
260
|
+
...payload,
|
|
261
|
+
createdAt: now(),
|
|
262
|
+
updatedAt: now(),
|
|
263
|
+
})
|
|
264
|
+
state.scheduledTasks.unshift(result)
|
|
265
|
+
})
|
|
266
|
+
return result
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function deleteTask(id) {
|
|
270
|
+
updateState((state) => {
|
|
271
|
+
state.scheduledTasks = state.scheduledTasks.filter((item) => item.id !== id)
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function updateTaskRun(id, patch = {}) {
|
|
276
|
+
let result = null
|
|
277
|
+
updateState((state) => {
|
|
278
|
+
const index = state.scheduledTasks.findIndex((item) => item.id === id)
|
|
279
|
+
if (index === -1) throw new Error('Task not found')
|
|
280
|
+
result = normalizeTask({
|
|
281
|
+
...state.scheduledTasks[index],
|
|
282
|
+
...patch,
|
|
283
|
+
updatedAt: now(),
|
|
284
|
+
})
|
|
285
|
+
state.scheduledTasks[index] = result
|
|
286
|
+
})
|
|
287
|
+
return result
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getHolySheepApiConfig() {
|
|
291
|
+
return loadState().holySheepApi
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function saveHolySheepApiConfig(config = {}) {
|
|
295
|
+
let result = null
|
|
296
|
+
updateState((state) => {
|
|
297
|
+
state.holySheepApi = {
|
|
298
|
+
apiKey: String(config.apiKey || state.holySheepApi.apiKey || ''),
|
|
299
|
+
baseUrl: String(config.baseUrl || state.holySheepApi.baseUrl || ''),
|
|
300
|
+
model: String(config.model || state.holySheepApi.model || ''),
|
|
301
|
+
}
|
|
302
|
+
result = clone(state.holySheepApi)
|
|
303
|
+
})
|
|
304
|
+
return result
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = {
|
|
308
|
+
STATE_FILE,
|
|
309
|
+
loadState,
|
|
310
|
+
saveState,
|
|
311
|
+
listConversations,
|
|
312
|
+
getConversation,
|
|
313
|
+
createConversation,
|
|
314
|
+
updateConversation,
|
|
315
|
+
addMessage,
|
|
316
|
+
searchWorkspace,
|
|
317
|
+
listTasks,
|
|
318
|
+
getTask,
|
|
319
|
+
saveTask,
|
|
320
|
+
deleteTask,
|
|
321
|
+
updateTaskRun,
|
|
322
|
+
getHolySheepApiConfig,
|
|
323
|
+
saveHolySheepApiConfig,
|
|
324
|
+
ensureConversation,
|
|
325
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const assert = require('assert')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-workspace-test-'))
|
|
7
|
+
process.env.HOME = tempHome
|
|
8
|
+
|
|
9
|
+
const store = require('../src/webui/workspace-store')
|
|
10
|
+
|
|
11
|
+
store.saveHolySheepApiConfig({
|
|
12
|
+
apiKey: 'cr_test_runtime',
|
|
13
|
+
baseUrl: 'https://api.holysheep.ai/v1',
|
|
14
|
+
model: 'gpt-5.4',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const conversation = store.createConversation({
|
|
18
|
+
title: 'Build workspace shell',
|
|
19
|
+
toolId: 'codex',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
store.addMessage(conversation.id, {
|
|
23
|
+
role: 'user',
|
|
24
|
+
content: 'Need AionUi-style sidebar and search box',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
store.addMessage(conversation.id, {
|
|
28
|
+
role: 'assistant',
|
|
29
|
+
content: 'Implement the shell and keep the HolySheep tool APIs intact.',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const task = store.saveTask({
|
|
33
|
+
title: 'Nightly summary',
|
|
34
|
+
prompt: 'Summarize the latest workspace changes',
|
|
35
|
+
schedule: '1h',
|
|
36
|
+
active: true,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const stateFile = store.STATE_FILE
|
|
40
|
+
assert.ok(fs.existsSync(stateFile), 'workspace state file should be created')
|
|
41
|
+
|
|
42
|
+
const loadedConversation = store.getConversation(conversation.id)
|
|
43
|
+
assert.ok(loadedConversation, 'conversation should be persisted')
|
|
44
|
+
assert.strictEqual(loadedConversation.messages.length, 2, 'conversation messages should be stored')
|
|
45
|
+
|
|
46
|
+
const search = store.searchWorkspace('sidebar')
|
|
47
|
+
assert.strictEqual(search.conversations.length, 1, 'conversation search should match message content')
|
|
48
|
+
|
|
49
|
+
const tasks = store.listTasks()
|
|
50
|
+
assert.strictEqual(tasks.length, 1, 'scheduled task should be persisted')
|
|
51
|
+
assert.strictEqual(tasks[0].id, task.id, 'task id should remain stable')
|
|
52
|
+
|
|
53
|
+
const runtime = store.getHolySheepApiConfig()
|
|
54
|
+
assert.strictEqual(runtime.apiKey, 'cr_test_runtime', 'runtime api key should be stored')
|
|
55
|
+
assert.strictEqual(runtime.model, 'gpt-5.4', 'runtime model should be stored')
|
|
56
|
+
|
|
57
|
+
console.log('workspace store test passed')
|