@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
package/src/webui/server.js
CHANGED
|
@@ -15,9 +15,14 @@ const path = require('path')
|
|
|
15
15
|
const { execSync, spawn } = require('child_process')
|
|
16
16
|
const { loadConfig, saveConfig, getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, BASE_URL_CLAUDE_RELAY, SHOP_URL, CONFIG_FILE } = require('../utils/config')
|
|
17
17
|
const { removeEnvFromShell, writeEnvToShell, getShellRcFiles } = require('../utils/shell')
|
|
18
|
-
const {
|
|
18
|
+
const { commandExistsAsync } = require('../utils/which')
|
|
19
19
|
const TOOLS = require('../tools')
|
|
20
20
|
const pkg = require('../../package.json')
|
|
21
|
+
const workspaceStore = require('./workspace-store')
|
|
22
|
+
const workspaceRuntime = require('./workspace-runtime')
|
|
23
|
+
|
|
24
|
+
const TOOL_CHECK_CACHE_TTL_MS = 10_000
|
|
25
|
+
const toolStateCache = new Map()
|
|
21
26
|
|
|
22
27
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
23
28
|
|
|
@@ -92,6 +97,52 @@ function parseBody(req) {
|
|
|
92
97
|
})
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
function getToolCommand(toolId) {
|
|
101
|
+
const cmds = {
|
|
102
|
+
'claude-code': 'claude',
|
|
103
|
+
'codex': 'codex',
|
|
104
|
+
'droid': 'droid',
|
|
105
|
+
'gemini-cli': 'gemini',
|
|
106
|
+
'opencode': 'opencode',
|
|
107
|
+
'openclaw': 'openclaw',
|
|
108
|
+
'aider': 'aider',
|
|
109
|
+
'env-config': null,
|
|
110
|
+
}
|
|
111
|
+
return cmds[toolId] ?? null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function detectToolInstalled(tool) {
|
|
115
|
+
if (tool.id === 'env-config') return true
|
|
116
|
+
|
|
117
|
+
const now = Date.now()
|
|
118
|
+
const cached = toolStateCache.get(tool.id)
|
|
119
|
+
if (cached && now - cached.checkedAt < TOOL_CHECK_CACHE_TTL_MS) {
|
|
120
|
+
return cached.installed
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const command = getToolCommand(tool.id)
|
|
124
|
+
const installed = command ? await commandExistsAsync(command) : false
|
|
125
|
+
toolStateCache.set(tool.id, { installed, checkedAt: now })
|
|
126
|
+
return installed
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function buildToolSummary(tool) {
|
|
130
|
+
const installed = await detectToolInstalled(tool)
|
|
131
|
+
return {
|
|
132
|
+
id: tool.id,
|
|
133
|
+
name: tool.name,
|
|
134
|
+
installed,
|
|
135
|
+
configured: installed ? (tool.isConfigured?.() || false) : false,
|
|
136
|
+
version: installed ? await getVersionAsync(tool) : null,
|
|
137
|
+
installCmd: tool.installCmd,
|
|
138
|
+
hint: tool.hint || null,
|
|
139
|
+
launchCmd: tool.launchCmd || null,
|
|
140
|
+
canAutoInstall: !!AUTO_INSTALL[tool.id],
|
|
141
|
+
canUpgrade: !!UPGRADABLE_TOOLS.find((item) => item.id === tool.id),
|
|
142
|
+
npmPkg: UPGRADABLE_TOOLS.find((item) => item.id === tool.id)?.npmPkg || null,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
95
146
|
function json(res, data, status = 200) {
|
|
96
147
|
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
97
148
|
res.end(JSON.stringify(data))
|
|
@@ -190,6 +241,11 @@ async function handleLogin(req, res) {
|
|
|
190
241
|
const valid = await validateApiKey(apiKey)
|
|
191
242
|
if (!valid) return json(res, { success: false, message: 'API Key 无效' }, 401)
|
|
192
243
|
saveConfig({ apiKey, savedAt: new Date().toISOString() })
|
|
244
|
+
workspaceStore.saveHolySheepApiConfig({
|
|
245
|
+
apiKey,
|
|
246
|
+
baseUrl: workspaceStore.getHolySheepApiConfig().baseUrl || BASE_URL_OPENAI,
|
|
247
|
+
model: workspaceStore.getHolySheepApiConfig().model || 'gpt-5.4',
|
|
248
|
+
})
|
|
193
249
|
json(res, { success: true, apiKey: maskKey(apiKey) })
|
|
194
250
|
} catch (e) {
|
|
195
251
|
json(res, { success: false, message: `验证失败: ${e.message}` }, 500)
|
|
@@ -201,6 +257,11 @@ async function handleLogout(_req, res) {
|
|
|
201
257
|
delete config.apiKey
|
|
202
258
|
delete config.savedAt
|
|
203
259
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
|
|
260
|
+
workspaceStore.saveHolySheepApiConfig({
|
|
261
|
+
apiKey: '',
|
|
262
|
+
baseUrl: workspaceStore.getHolySheepApiConfig().baseUrl || BASE_URL_OPENAI,
|
|
263
|
+
model: workspaceStore.getHolySheepApiConfig().model || 'gpt-5.4',
|
|
264
|
+
})
|
|
204
265
|
json(res, { success: true })
|
|
205
266
|
}
|
|
206
267
|
|
|
@@ -240,19 +301,7 @@ async function handleDoctor(_req, res) {
|
|
|
240
301
|
const nodeMajor = parseInt(process.version.slice(1), 10)
|
|
241
302
|
|
|
242
303
|
// Tools
|
|
243
|
-
const tools = TOOLS.map(
|
|
244
|
-
const installed = t.checkInstalled()
|
|
245
|
-
return {
|
|
246
|
-
id: t.id,
|
|
247
|
-
name: t.name,
|
|
248
|
-
installed,
|
|
249
|
-
configured: installed ? (t.isConfigured?.() || false) : false,
|
|
250
|
-
version: installed ? getVersion(t) : null,
|
|
251
|
-
installCmd: t.installCmd,
|
|
252
|
-
hint: t.hint || null,
|
|
253
|
-
launchCmd: t.launchCmd || null,
|
|
254
|
-
}
|
|
255
|
-
})
|
|
304
|
+
const tools = await Promise.all(TOOLS.map((tool) => buildToolSummary(tool)))
|
|
256
305
|
|
|
257
306
|
// Connectivity
|
|
258
307
|
let connectivity = { ok: false, modelCount: 0 }
|
|
@@ -297,22 +346,7 @@ function isClaudeProxyRunning() {
|
|
|
297
346
|
}
|
|
298
347
|
|
|
299
348
|
async function handleTools(_req, res) {
|
|
300
|
-
const tools = await Promise.all(TOOLS.map(
|
|
301
|
-
const installed = t.checkInstalled()
|
|
302
|
-
return {
|
|
303
|
-
id: t.id,
|
|
304
|
-
name: t.name,
|
|
305
|
-
installed,
|
|
306
|
-
configured: installed ? (t.isConfigured?.() || false) : false,
|
|
307
|
-
version: installed ? await getVersionAsync(t) : null,
|
|
308
|
-
installCmd: t.installCmd,
|
|
309
|
-
hint: t.hint || null,
|
|
310
|
-
launchCmd: t.launchCmd || null,
|
|
311
|
-
canAutoInstall: !!AUTO_INSTALL[t.id],
|
|
312
|
-
canUpgrade: !!UPGRADABLE_TOOLS.find(u => u.id === t.id),
|
|
313
|
-
npmPkg: UPGRADABLE_TOOLS.find(u => u.id === t.id)?.npmPkg || null,
|
|
314
|
-
}
|
|
315
|
-
}))
|
|
349
|
+
const tools = await Promise.all(TOOLS.map((tool) => buildToolSummary(tool)))
|
|
316
350
|
|
|
317
351
|
// 追加 claude-proxy 虚拟工具
|
|
318
352
|
const proxyState = isClaudeProxyRunning()
|
|
@@ -1034,6 +1068,192 @@ function handleModels(_req, res) {
|
|
|
1034
1068
|
])
|
|
1035
1069
|
}
|
|
1036
1070
|
|
|
1071
|
+
function getWorkspacePayload() {
|
|
1072
|
+
const config = workspaceRuntime.normalizeRuntimeConfig({})
|
|
1073
|
+
const hasRuntimeConfig = Boolean(config.apiKey && config.baseUrl && config.model)
|
|
1074
|
+
return {
|
|
1075
|
+
conversations: workspaceStore.listConversations(),
|
|
1076
|
+
scheduledTasks: workspaceStore.listTasks(),
|
|
1077
|
+
holySheepApi: {
|
|
1078
|
+
...config,
|
|
1079
|
+
apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
|
|
1080
|
+
ready: hasRuntimeConfig,
|
|
1081
|
+
},
|
|
1082
|
+
tools: TOOLS.map((tool) => ({
|
|
1083
|
+
id: tool.id,
|
|
1084
|
+
name: tool.name,
|
|
1085
|
+
launchCmd: tool.launchCmd || null,
|
|
1086
|
+
hint: tool.hint || null,
|
|
1087
|
+
})),
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function handleWorkspaceState(_req, res) {
|
|
1092
|
+
json(res, getWorkspacePayload())
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function handleWorkspaceSearch(req, res, url) {
|
|
1096
|
+
const query = url.searchParams.get('q') || ''
|
|
1097
|
+
json(res, workspaceStore.searchWorkspace(query))
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
async function handleWorkspaceApiConfig(req, res) {
|
|
1101
|
+
if (req.method === 'GET') {
|
|
1102
|
+
const config = workspaceRuntime.normalizeRuntimeConfig({})
|
|
1103
|
+
return json(res, {
|
|
1104
|
+
apiKey: config.apiKey,
|
|
1105
|
+
apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
|
|
1106
|
+
baseUrl: config.baseUrl,
|
|
1107
|
+
model: config.model,
|
|
1108
|
+
ready: Boolean(config.apiKey && config.baseUrl && config.model),
|
|
1109
|
+
})
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const body = await parseBody(req)
|
|
1113
|
+
const apiKey = String(body.apiKey || '').trim()
|
|
1114
|
+
const baseUrl = String(body.baseUrl || '').trim() || BASE_URL_OPENAI
|
|
1115
|
+
const model = String(body.model || '').trim()
|
|
1116
|
+
if (!apiKey || !apiKey.startsWith('cr_')) {
|
|
1117
|
+
return json(res, { success: false, error: 'HolySheep API Key 必须以 cr_ 开头' }, 400)
|
|
1118
|
+
}
|
|
1119
|
+
if (!model) {
|
|
1120
|
+
return json(res, { success: false, error: '模型不能为空' }, 400)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
workspaceStore.saveHolySheepApiConfig({ apiKey, baseUrl, model })
|
|
1124
|
+
saveConfig({ apiKey, savedAt: new Date().toISOString() })
|
|
1125
|
+
json(res, { success: true, config: getWorkspacePayload().holySheepApi })
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async function handleWorkspaceConversations(req, res) {
|
|
1129
|
+
if (req.method === 'GET') {
|
|
1130
|
+
return json(res, workspaceStore.listConversations())
|
|
1131
|
+
}
|
|
1132
|
+
const body = await parseBody(req)
|
|
1133
|
+
const conversation = workspaceStore.createConversation({
|
|
1134
|
+
title: body.title,
|
|
1135
|
+
toolId: body.toolId,
|
|
1136
|
+
pinned: body.pinned,
|
|
1137
|
+
})
|
|
1138
|
+
json(res, { success: true, conversation })
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function handleWorkspaceConversationById(req, res, conversationId) {
|
|
1142
|
+
const conversation = workspaceStore.getConversation(conversationId)
|
|
1143
|
+
if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
|
|
1144
|
+
|
|
1145
|
+
if (req.method === 'GET') {
|
|
1146
|
+
return json(res, conversation)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (req.method === 'PATCH') {
|
|
1150
|
+
const body = await parseBody(req)
|
|
1151
|
+
const updated = workspaceStore.updateConversation(conversationId, {
|
|
1152
|
+
title: body.title,
|
|
1153
|
+
toolId: body.toolId,
|
|
1154
|
+
pinned: body.pinned,
|
|
1155
|
+
})
|
|
1156
|
+
return json(res, { success: true, conversation: updated })
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
return json(res, { success: false, error: 'Method not allowed' }, 405)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function handleWorkspaceConversationMessages(req, res, conversationId) {
|
|
1163
|
+
if (req.method === 'GET') {
|
|
1164
|
+
const conversation = workspaceStore.getConversation(conversationId)
|
|
1165
|
+
if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
|
|
1166
|
+
return json(res, conversation.messages)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const body = await parseBody(req)
|
|
1170
|
+
const content = String(body.content || '').trim()
|
|
1171
|
+
if (!content) return json(res, { success: false, error: '消息不能为空' }, 400)
|
|
1172
|
+
|
|
1173
|
+
try {
|
|
1174
|
+
const result = await workspaceRuntime.sendConversationMessage(conversationId, content, body.runtimeConfig || {})
|
|
1175
|
+
return json(res, {
|
|
1176
|
+
success: true,
|
|
1177
|
+
messages: [result.userMessage, result.assistantMessage],
|
|
1178
|
+
conversation: workspaceStore.getConversation(conversationId),
|
|
1179
|
+
})
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
return json(res, {
|
|
1182
|
+
success: false,
|
|
1183
|
+
error: error.message,
|
|
1184
|
+
conversation: workspaceStore.getConversation(conversationId),
|
|
1185
|
+
}, 500)
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async function handleWorkspaceTasks(req, res) {
|
|
1190
|
+
if (req.method === 'GET') {
|
|
1191
|
+
return json(res, workspaceStore.listTasks())
|
|
1192
|
+
}
|
|
1193
|
+
const body = await parseBody(req)
|
|
1194
|
+
const title = String(body.title || '').trim()
|
|
1195
|
+
const prompt = String(body.prompt || '').trim()
|
|
1196
|
+
if (!title || !prompt) return json(res, { success: false, error: '任务标题和提示词不能为空' }, 400)
|
|
1197
|
+
|
|
1198
|
+
try {
|
|
1199
|
+
workspaceRuntime.parseScheduleToMs(body.schedule || '1h')
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
return json(res, { success: false, error: error.message }, 400)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const task = workspaceStore.saveTask({
|
|
1205
|
+
title,
|
|
1206
|
+
prompt,
|
|
1207
|
+
schedule: body.schedule || '1h',
|
|
1208
|
+
active: body.active !== false,
|
|
1209
|
+
conversationId: body.conversationId || null,
|
|
1210
|
+
modelOverride: body.modelOverride || '',
|
|
1211
|
+
})
|
|
1212
|
+
workspaceRuntime.rescheduleAllTasks()
|
|
1213
|
+
json(res, { success: true, task })
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async function handleWorkspaceTaskById(req, res, taskId) {
|
|
1217
|
+
if (req.method === 'DELETE') {
|
|
1218
|
+
workspaceStore.deleteTask(taskId)
|
|
1219
|
+
workspaceRuntime.rescheduleAllTasks()
|
|
1220
|
+
return json(res, { success: true })
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (req.method === 'PATCH') {
|
|
1224
|
+
const body = await parseBody(req)
|
|
1225
|
+
if (body.schedule) {
|
|
1226
|
+
try {
|
|
1227
|
+
workspaceRuntime.parseScheduleToMs(body.schedule)
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
return json(res, { success: false, error: error.message }, 400)
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const task = workspaceStore.saveTask({
|
|
1233
|
+
id: taskId,
|
|
1234
|
+
title: body.title,
|
|
1235
|
+
prompt: body.prompt,
|
|
1236
|
+
schedule: body.schedule,
|
|
1237
|
+
active: body.active,
|
|
1238
|
+
conversationId: body.conversationId,
|
|
1239
|
+
modelOverride: body.modelOverride,
|
|
1240
|
+
})
|
|
1241
|
+
workspaceRuntime.rescheduleAllTasks()
|
|
1242
|
+
return json(res, { success: true, task })
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (req.method === 'POST') {
|
|
1246
|
+
try {
|
|
1247
|
+
const result = await workspaceRuntime.runTask(taskId)
|
|
1248
|
+
return json(res, { success: true, result })
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
return json(res, { success: false, error: error.message }, 500)
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
return json(res, { success: false, error: 'Method not allowed' }, 405)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1037
1257
|
// ── Router ───────────────────────────────────────────────────────────────────
|
|
1038
1258
|
|
|
1039
1259
|
async function handleRequest(req, res) {
|
|
@@ -1062,6 +1282,17 @@ async function handleRequest(req, res) {
|
|
|
1062
1282
|
if (route === '/api/doctor' && req.method === 'GET') return await handleDoctor(req, res)
|
|
1063
1283
|
if (route === '/api/tools' && req.method === 'GET') return await handleTools(req, res)
|
|
1064
1284
|
if (route === '/api/models' && req.method === 'GET') return await handleModels(req, res)
|
|
1285
|
+
if (route === '/api/workspace/state' && req.method === 'GET') return await handleWorkspaceState(req, res)
|
|
1286
|
+
if (route === '/api/workspace/search' && req.method === 'GET') return await handleWorkspaceSearch(req, res, url)
|
|
1287
|
+
if (route === '/api/workspace/api-config' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceApiConfig(req, res)
|
|
1288
|
+
if (route === '/api/workspace/conversations' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceConversations(req, res)
|
|
1289
|
+
const conversationMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)$/)
|
|
1290
|
+
if (conversationMatch) return await handleWorkspaceConversationById(req, res, conversationMatch[1])
|
|
1291
|
+
const messagesMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)\/messages$/)
|
|
1292
|
+
if (messagesMatch) return await handleWorkspaceConversationMessages(req, res, messagesMatch[1])
|
|
1293
|
+
if (route === '/api/workspace/tasks' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceTasks(req, res)
|
|
1294
|
+
const taskMatch = route.match(/^\/api\/workspace\/tasks\/([^/]+)$/)
|
|
1295
|
+
if (taskMatch) return await handleWorkspaceTaskById(req, res, taskMatch[1])
|
|
1065
1296
|
if (route === '/api/upgrade' && req.method === 'POST') return await handleUpgrade(req, res)
|
|
1066
1297
|
if (route === '/api/tool/install' && req.method === 'POST') return await handleToolInstall(req, res)
|
|
1067
1298
|
if (route === '/api/tool/configure' && req.method === 'POST') return await handleToolConfigure(req, res)
|
|
@@ -1113,13 +1344,19 @@ function startServer(port) {
|
|
|
1113
1344
|
setTimeout(() => {
|
|
1114
1345
|
const retry = http.createServer(handleRequest)
|
|
1115
1346
|
retry.on('error', (err2) => reject(err2))
|
|
1116
|
-
retry.listen(port, '127.0.0.1', () =>
|
|
1347
|
+
retry.listen(port, '127.0.0.1', () => {
|
|
1348
|
+
workspaceRuntime.startScheduler()
|
|
1349
|
+
resolve(retry)
|
|
1350
|
+
})
|
|
1117
1351
|
}, 500)
|
|
1118
1352
|
} else {
|
|
1119
1353
|
reject(err)
|
|
1120
1354
|
}
|
|
1121
1355
|
})
|
|
1122
|
-
server.listen(port, '127.0.0.1', () =>
|
|
1356
|
+
server.listen(port, '127.0.0.1', () => {
|
|
1357
|
+
workspaceRuntime.startScheduler()
|
|
1358
|
+
resolve(server)
|
|
1359
|
+
})
|
|
1123
1360
|
})
|
|
1124
1361
|
}
|
|
1125
1362
|
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fetch = require('node-fetch')
|
|
4
|
+
const {
|
|
5
|
+
addMessage,
|
|
6
|
+
createConversation,
|
|
7
|
+
ensureConversation,
|
|
8
|
+
getConversation,
|
|
9
|
+
getHolySheepApiConfig,
|
|
10
|
+
listTasks,
|
|
11
|
+
updateConversation,
|
|
12
|
+
updateTaskRun,
|
|
13
|
+
} = require('./workspace-store')
|
|
14
|
+
const { getApiKey, BASE_URL_OPENAI } = require('../utils/config')
|
|
15
|
+
|
|
16
|
+
const schedulerHandles = new Map()
|
|
17
|
+
|
|
18
|
+
function normalizeRuntimeConfig(config = {}) {
|
|
19
|
+
const saved = getHolySheepApiConfig()
|
|
20
|
+
const apiKey = String(config.apiKey || saved.apiKey || getApiKey() || '').trim()
|
|
21
|
+
const baseUrl = String(config.baseUrl || saved.baseUrl || BASE_URL_OPENAI).replace(/\/+$/, '')
|
|
22
|
+
const model = String(config.model || saved.model || '').trim()
|
|
23
|
+
return { apiKey, baseUrl, model }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertRuntimeConfig(config) {
|
|
27
|
+
if (!config.apiKey || !config.apiKey.startsWith('cr_')) {
|
|
28
|
+
throw new Error('HolySheep API Key is required')
|
|
29
|
+
}
|
|
30
|
+
if (!config.baseUrl) {
|
|
31
|
+
throw new Error('HolySheep API Base URL is required')
|
|
32
|
+
}
|
|
33
|
+
if (!config.model) {
|
|
34
|
+
throw new Error('HolySheep API model is required')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildSystemPrompt(toolId) {
|
|
39
|
+
const toolName = String(toolId || 'codex')
|
|
40
|
+
return [
|
|
41
|
+
`You are operating inside HolySheep Workspace.`,
|
|
42
|
+
`The selected coding tool is "${toolName}".`,
|
|
43
|
+
`Respond with concise, implementation-focused guidance suitable for a coding assistant workspace.`,
|
|
44
|
+
`When you mention commands, prefer copy-paste-ready shell commands.`,
|
|
45
|
+
].join(' ')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function requestCompletion(messages, config) {
|
|
49
|
+
const controller = new AbortController()
|
|
50
|
+
const timeout = setTimeout(() => controller.abort(), 20_000)
|
|
51
|
+
let response
|
|
52
|
+
try {
|
|
53
|
+
response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
model: config.model,
|
|
61
|
+
temperature: 0.2,
|
|
62
|
+
messages,
|
|
63
|
+
}),
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
})
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.name === 'AbortError') {
|
|
68
|
+
throw new Error('HolySheep API request timed out')
|
|
69
|
+
}
|
|
70
|
+
throw error
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timeout)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const payload = await response.json().catch(() => null)
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const reason = payload?.error?.message || payload?.message || `HTTP ${response.status}`
|
|
78
|
+
throw new Error(reason)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const content = payload?.choices?.[0]?.message?.content
|
|
82
|
+
if (typeof content === 'string' && content.trim()) return content.trim()
|
|
83
|
+
if (Array.isArray(content)) {
|
|
84
|
+
const text = content
|
|
85
|
+
.map((part) => {
|
|
86
|
+
if (typeof part === 'string') return part
|
|
87
|
+
if (typeof part?.text === 'string') return part.text
|
|
88
|
+
if (typeof part?.content === 'string') return part.content
|
|
89
|
+
return ''
|
|
90
|
+
})
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join('\n')
|
|
93
|
+
.trim()
|
|
94
|
+
if (text) return text
|
|
95
|
+
}
|
|
96
|
+
const fallbacks = [
|
|
97
|
+
payload?.choices?.[0]?.text,
|
|
98
|
+
payload?.output_text,
|
|
99
|
+
payload?.response,
|
|
100
|
+
payload?.message,
|
|
101
|
+
]
|
|
102
|
+
for (const item of fallbacks) {
|
|
103
|
+
if (typeof item === 'string' && item.trim()) return item.trim()
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(payload?.output)) {
|
|
106
|
+
const text = payload.output
|
|
107
|
+
.map((part) => {
|
|
108
|
+
if (typeof part?.content === 'string') return part.content
|
|
109
|
+
if (typeof part?.text === 'string') return part.text
|
|
110
|
+
if (Array.isArray(part?.content)) {
|
|
111
|
+
return part.content.map((child) => child?.text || child?.content || '').join('\n')
|
|
112
|
+
}
|
|
113
|
+
return ''
|
|
114
|
+
})
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join('\n')
|
|
117
|
+
.trim()
|
|
118
|
+
if (text) return text
|
|
119
|
+
}
|
|
120
|
+
return 'No response content returned.'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function sendConversationMessage(conversationId, input, options = {}) {
|
|
124
|
+
const conversation = ensureConversation(conversationId)
|
|
125
|
+
const config = normalizeRuntimeConfig(options)
|
|
126
|
+
assertRuntimeConfig(config)
|
|
127
|
+
|
|
128
|
+
const userMessage = addMessage(conversationId, {
|
|
129
|
+
role: 'user',
|
|
130
|
+
content: String(input || ''),
|
|
131
|
+
meta: {
|
|
132
|
+
source: 'workspace',
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const messages = [
|
|
138
|
+
{ role: 'system', content: buildSystemPrompt(conversation.toolId) },
|
|
139
|
+
...conversation.messages
|
|
140
|
+
.concat(userMessage)
|
|
141
|
+
.map((message) => ({
|
|
142
|
+
role: message.role,
|
|
143
|
+
content: message.content,
|
|
144
|
+
})),
|
|
145
|
+
]
|
|
146
|
+
const reply = await requestCompletion(messages, config)
|
|
147
|
+
const assistantMessage = addMessage(conversationId, {
|
|
148
|
+
role: 'assistant',
|
|
149
|
+
content: reply,
|
|
150
|
+
meta: {
|
|
151
|
+
model: config.model,
|
|
152
|
+
source: 'holysheep-api',
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
updateConversation(conversationId, {
|
|
156
|
+
summary: reply.slice(0, 240),
|
|
157
|
+
})
|
|
158
|
+
return { userMessage, assistantMessage }
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const assistantMessage = addMessage(conversationId, {
|
|
161
|
+
role: 'assistant',
|
|
162
|
+
content: `Error: ${error.message}`,
|
|
163
|
+
status: 'error',
|
|
164
|
+
meta: {
|
|
165
|
+
model: config.model,
|
|
166
|
+
source: 'holysheep-api',
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
updateConversation(conversationId, {
|
|
170
|
+
summary: assistantMessage.content.slice(0, 240),
|
|
171
|
+
})
|
|
172
|
+
throw error
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseScheduleToMs(schedule) {
|
|
177
|
+
const value = String(schedule || '').trim().toLowerCase()
|
|
178
|
+
const match = value.match(/^(\d+)\s*([smhd])$/)
|
|
179
|
+
if (!match) throw new Error('Schedule must use format like 30s, 5m, 1h, 1d')
|
|
180
|
+
const amount = Number(match[1])
|
|
181
|
+
const unit = match[2]
|
|
182
|
+
if (!Number.isFinite(amount) || amount <= 0) throw new Error('Invalid schedule amount')
|
|
183
|
+
const factors = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
|
184
|
+
return amount * factors[unit]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function runTask(taskId, options = {}) {
|
|
188
|
+
const task = listTasks().find((item) => item.id === taskId)
|
|
189
|
+
if (!task) throw new Error('Task not found')
|
|
190
|
+
|
|
191
|
+
const config = normalizeRuntimeConfig({
|
|
192
|
+
...options,
|
|
193
|
+
model: task.modelOverride || options.model,
|
|
194
|
+
})
|
|
195
|
+
assertRuntimeConfig(config)
|
|
196
|
+
updateTaskRun(taskId, {
|
|
197
|
+
lastStatus: 'running',
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
let conversationId = task.conversationId
|
|
201
|
+
if (!conversationId) {
|
|
202
|
+
const conversation = createConversation({
|
|
203
|
+
title: task.title,
|
|
204
|
+
toolId: 'codex',
|
|
205
|
+
})
|
|
206
|
+
conversationId = conversation.id
|
|
207
|
+
updateTaskRun(taskId, { conversationId })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const { assistantMessage } = await sendConversationMessage(conversationId, task.prompt, config)
|
|
212
|
+
updateTaskRun(taskId, {
|
|
213
|
+
lastRunAt: new Date().toISOString(),
|
|
214
|
+
lastStatus: 'ok',
|
|
215
|
+
lastResult: assistantMessage.content,
|
|
216
|
+
conversationId,
|
|
217
|
+
})
|
|
218
|
+
return assistantMessage
|
|
219
|
+
} catch (error) {
|
|
220
|
+
updateTaskRun(taskId, {
|
|
221
|
+
lastRunAt: new Date().toISOString(),
|
|
222
|
+
lastStatus: 'error',
|
|
223
|
+
lastResult: error.message,
|
|
224
|
+
conversationId,
|
|
225
|
+
})
|
|
226
|
+
throw error
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function clearTaskHandle(taskId) {
|
|
231
|
+
const existing = schedulerHandles.get(taskId)
|
|
232
|
+
if (!existing) return
|
|
233
|
+
clearInterval(existing)
|
|
234
|
+
schedulerHandles.delete(taskId)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function scheduleTask(task) {
|
|
238
|
+
clearTaskHandle(task.id)
|
|
239
|
+
if (!task.active) return
|
|
240
|
+
|
|
241
|
+
let intervalMs
|
|
242
|
+
try {
|
|
243
|
+
intervalMs = parseScheduleToMs(task.schedule)
|
|
244
|
+
} catch {
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const handle = setInterval(() => {
|
|
249
|
+
void runTask(task.id).catch(() => {})
|
|
250
|
+
}, intervalMs)
|
|
251
|
+
schedulerHandles.set(task.id, handle)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function rescheduleAllTasks() {
|
|
255
|
+
const tasks = listTasks()
|
|
256
|
+
const seen = new Set(tasks.map((task) => task.id))
|
|
257
|
+
for (const taskId of schedulerHandles.keys()) {
|
|
258
|
+
if (!seen.has(taskId)) {
|
|
259
|
+
clearTaskHandle(taskId)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const task of tasks) {
|
|
263
|
+
scheduleTask(task)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stopScheduler() {
|
|
268
|
+
for (const taskId of schedulerHandles.keys()) {
|
|
269
|
+
clearTaskHandle(taskId)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function startScheduler() {
|
|
274
|
+
rescheduleAllTasks()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = {
|
|
278
|
+
normalizeRuntimeConfig,
|
|
279
|
+
assertRuntimeConfig,
|
|
280
|
+
sendConversationMessage,
|
|
281
|
+
parseScheduleToMs,
|
|
282
|
+
runTask,
|
|
283
|
+
startScheduler,
|
|
284
|
+
stopScheduler,
|
|
285
|
+
rescheduleAllTasks,
|
|
286
|
+
createConversation,
|
|
287
|
+
getConversation,
|
|
288
|
+
}
|