openbot 0.4.0 → 0.4.2
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/dist/app/cli.js +1 -1
- package/dist/app/config.js +10 -0
- package/dist/app/server.js +200 -3
- package/dist/harness/index.js +18 -0
- package/dist/plugins/approval/index.js +35 -20
- package/dist/plugins/bash/index.js +195 -0
- package/dist/plugins/delegation/index.js +4 -2
- package/dist/plugins/openbot/context.js +54 -9
- package/dist/plugins/openbot/history.js +47 -1
- package/dist/plugins/openbot/index.js +43 -3
- package/dist/plugins/openbot/runtime.js +91 -27
- package/dist/plugins/openbot/system-prompt.js +21 -1
- package/dist/plugins/plugin-manager/index.js +87 -3
- package/dist/plugins/shell/index.js +2 -1
- package/dist/plugins/storage/files.js +67 -0
- package/dist/plugins/storage/index.js +184 -7
- package/dist/plugins/storage/service.js +201 -44
- package/dist/plugins/ui/index.js +109 -150
- package/dist/services/abort.js +43 -0
- package/dist/services/plugins/registry.js +5 -3
- package/dist/services/plugins/service.js +66 -11
- package/docs/agents.md +5 -8
- package/docs/architecture.md +1 -1
- package/docs/plugins.md +28 -7
- package/docs/templates/AGENT.example.md +4 -4
- package/package.json +1 -1
- package/src/app/cli.ts +1 -1
- package/src/app/config.ts +13 -0
- package/src/app/server.ts +235 -3
- package/src/app/types.ts +284 -14
- package/src/harness/index.ts +21 -0
- package/src/plugins/approval/index.ts +37 -20
- package/src/plugins/bash/index.ts +232 -0
- package/src/plugins/delegation/index.ts +5 -2
- package/src/plugins/openbot/context.ts +58 -9
- package/src/plugins/openbot/history.ts +52 -1
- package/src/plugins/openbot/index.ts +45 -3
- package/src/plugins/openbot/runtime.ts +121 -27
- package/src/plugins/openbot/system-prompt.ts +21 -1
- package/src/plugins/plugin-manager/index.ts +105 -3
- package/src/plugins/storage/files.ts +81 -0
- package/src/plugins/storage/index.ts +198 -8
- package/src/plugins/storage/service.ts +267 -44
- package/src/plugins/ui/index.ts +123 -0
- package/src/services/abort.ts +46 -0
- package/src/services/plugins/domain.ts +34 -1
- package/src/services/plugins/registry.ts +5 -3
- package/src/services/plugins/service.ts +136 -45
- package/src/services/plugins/types.ts +5 -1
- package/src/plugins/shell/index.ts +0 -123
|
@@ -8,7 +8,6 @@ import { Storage } from '../../services/plugins/domain.js';
|
|
|
8
8
|
import type { ToolDefinition } from '../../services/plugins/types.js';
|
|
9
9
|
import {
|
|
10
10
|
ORCHESTRATOR_AGENT_ID,
|
|
11
|
-
getContextBudgetForModel,
|
|
12
11
|
buildContext,
|
|
13
12
|
} from './context.js';
|
|
14
13
|
import { saveConfig } from '../../app/config.js';
|
|
@@ -20,6 +19,8 @@ export interface OpenBotRuntimeOptions {
|
|
|
20
19
|
storage?: Storage;
|
|
21
20
|
/** Tool definitions merged from all tool plugins attached to this agent. */
|
|
22
21
|
toolDefinitions?: Record<string, ToolDefinition>;
|
|
22
|
+
/** Fires when the run is stopped; cancels the in-flight LLM call. */
|
|
23
|
+
abortSignal?: AbortSignal;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function resolveModel(modelString: string): LanguageModel {
|
|
@@ -44,12 +45,7 @@ async function buildSystemPrompt(
|
|
|
44
45
|
): Promise<string> {
|
|
45
46
|
const context = await buildContext(state, storage);
|
|
46
47
|
|
|
47
|
-
const
|
|
48
|
-
state.agentId === ORCHESTRATOR_AGENT_ID
|
|
49
|
-
? (state.agentDetails?.instructions?.trim() || OPENBOT_SYSTEM_PROMPT)
|
|
50
|
-
: OPENBOT_SYSTEM_PROMPT;
|
|
51
|
-
|
|
52
|
-
const sections = [instructions, '', context];
|
|
48
|
+
const sections = [OPENBOT_SYSTEM_PROMPT, '', context];
|
|
53
49
|
|
|
54
50
|
// Hardcoded naming hint logic
|
|
55
51
|
const threadState = state.threadDetails?.state as any;
|
|
@@ -71,23 +67,44 @@ async function buildSystemPrompt(
|
|
|
71
67
|
* a single `generateText` response execute one-by-one. We must wait for every ID
|
|
72
68
|
* in the batch before calling the LLM again — not after the first result.
|
|
73
69
|
*/
|
|
74
|
-
function createToolBatchTracker(
|
|
75
|
-
|
|
70
|
+
function createToolBatchTracker(
|
|
71
|
+
state: OpenBotState,
|
|
72
|
+
storage?: Storage,
|
|
73
|
+
channelId?: string,
|
|
74
|
+
threadId?: string,
|
|
75
|
+
) {
|
|
76
|
+
const save = async (ids?: string[]) => {
|
|
77
|
+
if (!storage || !channelId || !threadId) return;
|
|
78
|
+
try {
|
|
79
|
+
await storage.patchThreadState({
|
|
80
|
+
channelId,
|
|
81
|
+
threadId,
|
|
82
|
+
state: { pendingToolCallIds: ids },
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('[openbot] Failed to persist pendingToolCallIds:', error);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
76
88
|
|
|
77
89
|
return {
|
|
78
|
-
startBatch(toolCallIds: string[]) {
|
|
79
|
-
|
|
90
|
+
async startBatch(toolCallIds: string[]) {
|
|
91
|
+
state.pendingToolCallIds = [...toolCallIds];
|
|
92
|
+
await save(state.pendingToolCallIds);
|
|
80
93
|
},
|
|
81
|
-
clear() {
|
|
82
|
-
|
|
94
|
+
async clear() {
|
|
95
|
+
state.pendingToolCallIds = undefined;
|
|
96
|
+
await save(undefined);
|
|
83
97
|
},
|
|
84
98
|
/** Returns true when this result completes the batch (time to call the LLM again). */
|
|
85
|
-
recordResult(toolCallId: string): boolean {
|
|
86
|
-
if (!
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
async recordResult(toolCallId: string): Promise<boolean> {
|
|
100
|
+
if (!state.pendingToolCallIds?.includes(toolCallId)) return false;
|
|
101
|
+
state.pendingToolCallIds = state.pendingToolCallIds.filter((id) => id !== toolCallId);
|
|
102
|
+
const done = state.pendingToolCallIds.length === 0;
|
|
103
|
+
if (done) {
|
|
104
|
+
state.pendingToolCallIds = undefined;
|
|
105
|
+
}
|
|
106
|
+
await save(state.pendingToolCallIds);
|
|
107
|
+
return done;
|
|
91
108
|
},
|
|
92
109
|
};
|
|
93
110
|
}
|
|
@@ -106,11 +123,11 @@ export const openbotRuntime =
|
|
|
106
123
|
model: modelString = 'openai/gpt-4o-mini',
|
|
107
124
|
storage,
|
|
108
125
|
toolDefinitions = {},
|
|
126
|
+
abortSignal,
|
|
109
127
|
} = options;
|
|
110
128
|
|
|
111
129
|
let currentModelString = modelString;
|
|
112
130
|
let model = resolveModel(currentModelString);
|
|
113
|
-
const toolBatch = createToolBatchTracker();
|
|
114
131
|
|
|
115
132
|
const runLLM = async function* (
|
|
116
133
|
context: RuntimeContext<OpenBotState, OpenBotEvent>,
|
|
@@ -118,6 +135,14 @@ export const openbotRuntime =
|
|
|
118
135
|
trigger?: AgentInvokeEvent,
|
|
119
136
|
): AsyncGenerator<OpenBotEvent> {
|
|
120
137
|
if (!storage) return;
|
|
138
|
+
if (abortSignal?.aborted) return;
|
|
139
|
+
|
|
140
|
+
const toolBatch = createToolBatchTracker(
|
|
141
|
+
context.state,
|
|
142
|
+
storage,
|
|
143
|
+
context.state.channelId,
|
|
144
|
+
threadId || context.state.threadId,
|
|
145
|
+
);
|
|
121
146
|
|
|
122
147
|
// Capture parent metadata for event enrichment
|
|
123
148
|
const triggerEvent = trigger || context.state.triggerEvent;
|
|
@@ -136,8 +161,8 @@ export const openbotRuntime =
|
|
|
136
161
|
const messages = eventsToModelMessages(events);
|
|
137
162
|
|
|
138
163
|
// console.log('systemPrompt:::::::\n', systemPrompt);
|
|
139
|
-
// console.log('messages:::::::\n', JSON.stringify(messages
|
|
140
|
-
// console.log('toolDefinitions:::::::\n', JSON.stringify(toolDefinitions
|
|
164
|
+
// console.log('messages:::::::\n', JSON.stringify(messages));
|
|
165
|
+
// console.log('toolDefinitions:::::::\n', JSON.stringify(toolDefinitions));
|
|
141
166
|
|
|
142
167
|
try {
|
|
143
168
|
// Single LLM request — tool execution happens externally via action:* handlers.
|
|
@@ -148,6 +173,7 @@ export const openbotRuntime =
|
|
|
148
173
|
tools: toolDefinitions as Record<string, { description: string; inputSchema: any }>,
|
|
149
174
|
stopWhen: ({ steps }) => steps.length === 1,
|
|
150
175
|
allowSystemInMessages: true,
|
|
176
|
+
abortSignal,
|
|
151
177
|
});
|
|
152
178
|
|
|
153
179
|
const toolCalls = result.toolCalls ?? [];
|
|
@@ -192,7 +218,7 @@ export const openbotRuntime =
|
|
|
192
218
|
|
|
193
219
|
if (toolCalls.length > 0) {
|
|
194
220
|
// when multiple tool calls are made, Melony runtime handles them one by one, thats why we need to start a new batch
|
|
195
|
-
toolBatch.startBatch(toolCalls.map((tc) => tc.toolCallId));
|
|
221
|
+
await toolBatch.startBatch(toolCalls.map((tc) => tc.toolCallId));
|
|
196
222
|
|
|
197
223
|
for (const toolCall of toolCalls) {
|
|
198
224
|
yield {
|
|
@@ -206,9 +232,12 @@ export const openbotRuntime =
|
|
|
206
232
|
}
|
|
207
233
|
} else {
|
|
208
234
|
// clear the tool batch if there are no tool calls
|
|
209
|
-
toolBatch.clear();
|
|
235
|
+
await toolBatch.clear();
|
|
210
236
|
}
|
|
211
237
|
} catch (error: unknown) {
|
|
238
|
+
// Run was stopped — unwind quietly without surfacing an error.
|
|
239
|
+
if (abortSignal?.aborted) return;
|
|
240
|
+
|
|
212
241
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
213
242
|
const isApiKeyError =
|
|
214
243
|
errorMessage.includes('API key') ||
|
|
@@ -277,11 +306,67 @@ export const openbotRuntime =
|
|
|
277
306
|
return;
|
|
278
307
|
}
|
|
279
308
|
|
|
309
|
+
// Capture user info from meta if available
|
|
310
|
+
if (event.meta?.userName) {
|
|
311
|
+
context.state.currentUser = {
|
|
312
|
+
userName: event.meta.userName,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const threadId = event.meta?.threadId || context.state.threadId;
|
|
317
|
+
|
|
318
|
+
// Auto-add participants if tagged in the prompt
|
|
319
|
+
const content = (event as AgentInvokeEvent).data?.content;
|
|
320
|
+
if (content && storage) {
|
|
321
|
+
try {
|
|
322
|
+
const allAgents = await storage.getAgents();
|
|
323
|
+
const tags = content.match(/@([\w-]+)/g);
|
|
324
|
+
if (tags) {
|
|
325
|
+
const taggedAgentIds = tags.map((t) => t.slice(1));
|
|
326
|
+
const validAgentIds = taggedAgentIds.filter((id) =>
|
|
327
|
+
allAgents.some((a) => a.id === id),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const currentParticipants = context.state.channelDetails?.participants || [];
|
|
331
|
+
const newParticipants = [...new Set([...currentParticipants, ...validAgentIds])];
|
|
332
|
+
|
|
333
|
+
if (newParticipants.length > currentParticipants.length) {
|
|
334
|
+
// Update storage
|
|
335
|
+
await storage.patchChannelState({
|
|
336
|
+
channelId: context.state.channelId,
|
|
337
|
+
state: { participants: newParticipants },
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Refresh local state
|
|
341
|
+
context.state.channelDetails = await storage.getChannelDetails({
|
|
342
|
+
channelId: context.state.channelId,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Notify UI/others about the change
|
|
346
|
+
yield {
|
|
347
|
+
type: 'action:patch_channel_details:result',
|
|
348
|
+
data: { success: true, updatedFields: ['participants'] },
|
|
349
|
+
meta: {
|
|
350
|
+
agentId: context.state.agentId,
|
|
351
|
+
threadId,
|
|
352
|
+
},
|
|
353
|
+
} as OpenBotEvent;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
console.warn('[openbot] Failed to auto-add participants from tags:', error);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
280
361
|
// clear the tool batch if the agent is invoked
|
|
281
362
|
// this is to prevent the tool batch from being used for a new agent invocation
|
|
282
|
-
|
|
363
|
+
await createToolBatchTracker(
|
|
364
|
+
context.state,
|
|
365
|
+
storage,
|
|
366
|
+
context.state.channelId,
|
|
367
|
+
threadId,
|
|
368
|
+
).clear();
|
|
283
369
|
|
|
284
|
-
const threadId = event.meta?.threadId || context.state.threadId;
|
|
285
370
|
yield* runLLM(context, threadId, event as AgentInvokeEvent);
|
|
286
371
|
});
|
|
287
372
|
|
|
@@ -293,7 +378,16 @@ export const openbotRuntime =
|
|
|
293
378
|
|
|
294
379
|
const toolCallId = event.meta?.toolCallId;
|
|
295
380
|
// record the result of the tool call
|
|
296
|
-
if (
|
|
381
|
+
if (
|
|
382
|
+
!toolCallId ||
|
|
383
|
+
!(await createToolBatchTracker(
|
|
384
|
+
context.state,
|
|
385
|
+
storage,
|
|
386
|
+
context.state.channelId,
|
|
387
|
+
event.meta?.threadId || context.state.threadId,
|
|
388
|
+
).recordResult(toolCallId))
|
|
389
|
+
)
|
|
390
|
+
return;
|
|
297
391
|
|
|
298
392
|
const threadId = event.meta?.threadId || context.state.threadId;
|
|
299
393
|
yield* runLLM(context, threadId);
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
export const OPENBOT_SYSTEM_PROMPT = [
|
|
2
|
-
'
|
|
2
|
+
'# ROLE',
|
|
3
|
+
'You are an OpenBot, the main coordinator and router agent. Your primary role is to orchestrate specialized agents to help the human achieve their goals.',
|
|
4
|
+
'',
|
|
5
|
+
'# SECURITY POLICY',
|
|
6
|
+
'- **CRITICAL**: Never request API keys, passwords, or sensitive credentials via text or UI widgets; these are managed deterministically via secure forms and must never enter your context.',
|
|
7
|
+
'- **Credential Guidance**: If an agent or tool requires credentials, inform the user they can be managed under "Settings > Variables".',
|
|
8
|
+
'',
|
|
9
|
+
'# CORE MISSION',
|
|
10
|
+
'You almost never execute tasks yourself. Instead, you delegate tasks to specialized agents (channel participants). You act as a high-level manager, ensuring the right agent is working on the right task.',
|
|
11
|
+
'',
|
|
12
|
+
'# OPERATIONAL GUIDELINES',
|
|
13
|
+
'- **Channel and Threads**: The main and only way to communicate and act is through channels and threads. There might be a channel called "uncategorized" for general purpose communication.',
|
|
14
|
+
'- **Agent Participation**: ONLY add an agent via `patch_channel_details` if the user manually tags them (e.g., `@name`) AND they are missing from the `Participants` list in `ENVIRONMENT`.',
|
|
15
|
+
'- **Delegation**: NEVER delegate to an agent who is not a participant. Only if existing participants clearly cannot handle a task should you suggest relevant agents from the `INSTALLED AGENTS` list.',
|
|
16
|
+
'- **Bash Tool Usage**: You should use the `bash` tool very rarely. Only use it when the user explicitly requests a command to be run or when it is absolutely necessary for a task that no other participant can handle.',
|
|
17
|
+
'- **Context Awareness**: Use the provided ENVIRONMENT, CHANNEL SPECIFICATION, and MEMORIES to maintain continuity. Do not ask for information already present in these sections.',
|
|
18
|
+
'- **Durable Memory**: Use the `remember` tool to store important facts, preferences, or project details that should persist across sessions.',
|
|
19
|
+
'- **Structured Interaction**: Use the `render_widget` tool to collect information via forms, offer choices, or display lists. This is preferred over asking multiple separate questions in plain text.',
|
|
20
|
+
'',
|
|
21
|
+
'# COMMUNICATION STYLE',
|
|
22
|
+
'- Be always concise, professional, and proactive.',
|
|
3
23
|
].join('\n');
|
|
4
24
|
|
|
5
25
|
/** Shown in the API key setup form when no provider credentials are configured. */
|
|
@@ -3,7 +3,7 @@ import { STATE_AGENT_ID } from '../../app/agent-ids.js';
|
|
|
3
3
|
import { OpenBotEvent } from '../../app/types.js';
|
|
4
4
|
import {
|
|
5
5
|
pluginService,
|
|
6
|
-
|
|
6
|
+
resolveMarketplaceRegistry,
|
|
7
7
|
} from '../../services/plugins/service.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -55,13 +55,115 @@ export const pluginManagerPlugin: Plugin = {
|
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
builder.on('action:marketplace:list', async function* () {
|
|
58
|
-
const agents = await
|
|
58
|
+
const { agents, channels } = await resolveMarketplaceRegistry();
|
|
59
59
|
yield {
|
|
60
60
|
type: 'action:marketplace:list:result',
|
|
61
|
-
data: { success: true, agents },
|
|
61
|
+
data: { success: true, agents, channels },
|
|
62
62
|
} as OpenBotEvent;
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
builder.on('action:channel:install', async function* (event) {
|
|
66
|
+
try {
|
|
67
|
+
const {
|
|
68
|
+
channelId: instanceId,
|
|
69
|
+
name: templateName,
|
|
70
|
+
participants: customParticipants,
|
|
71
|
+
initialState: customInitialState,
|
|
72
|
+
} = event.data;
|
|
73
|
+
const { agents: marketplaceAgents, channels } = await resolveMarketplaceRegistry();
|
|
74
|
+
|
|
75
|
+
// Try to find the template by ID or Name
|
|
76
|
+
const channelListing =
|
|
77
|
+
channels.find((c) => c.id === instanceId) ||
|
|
78
|
+
channels.find((c) => c.name === templateName);
|
|
79
|
+
|
|
80
|
+
const channelId = instanceId;
|
|
81
|
+
const participants = customParticipants || channelListing?.participants || [];
|
|
82
|
+
const initialState = {
|
|
83
|
+
...(channelListing?.initialState || {}),
|
|
84
|
+
...(customInitialState || {}),
|
|
85
|
+
};
|
|
86
|
+
const spec = channelListing?.spec || '';
|
|
87
|
+
|
|
88
|
+
// 1. Auto-install participant agents if missing
|
|
89
|
+
for (const agentId of participants) {
|
|
90
|
+
const existingAgents = await storage.getAgents();
|
|
91
|
+
if (existingAgents.some((a) => a.id === agentId)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Not found locally, look in marketplace
|
|
96
|
+
const agentListing = marketplaceAgents.find((a) => a.id === agentId);
|
|
97
|
+
if (agentListing) {
|
|
98
|
+
console.log(`[plugin-manager] Auto-installing agent ${agentId} for channel ${channelId}`);
|
|
99
|
+
|
|
100
|
+
// Install plugins for this agent
|
|
101
|
+
for (const ref of agentListing.plugins) {
|
|
102
|
+
const installed = await pluginService.isInstalled(ref.id);
|
|
103
|
+
if (
|
|
104
|
+
!installed &&
|
|
105
|
+
ref.id.includes('/') === false &&
|
|
106
|
+
ref.id.includes('-plugin-') === false
|
|
107
|
+
) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!installed) {
|
|
111
|
+
try {
|
|
112
|
+
await pluginService.install({ packageName: ref.id });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn(`[plugins] Failed to pre-install plugin ${ref.id}`, err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Create the agent
|
|
120
|
+
await storage.createAgent({
|
|
121
|
+
agentId: agentListing.id,
|
|
122
|
+
name: agentListing.name,
|
|
123
|
+
description: agentListing.description,
|
|
124
|
+
image: agentListing.image,
|
|
125
|
+
instructions: agentListing.instructions,
|
|
126
|
+
plugins: agentListing.plugins,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 2. Create the channel
|
|
132
|
+
await storage.createChannel({
|
|
133
|
+
channelId,
|
|
134
|
+
spec,
|
|
135
|
+
initialState: {
|
|
136
|
+
...initialState,
|
|
137
|
+
participants,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const channelUrl = `/channels/${channelId}`;
|
|
142
|
+
yield {
|
|
143
|
+
type: 'action:channel:install:result',
|
|
144
|
+
data: { success: true, channelId, channelUrl },
|
|
145
|
+
} as OpenBotEvent;
|
|
146
|
+
|
|
147
|
+
yield {
|
|
148
|
+
type: 'agent:output',
|
|
149
|
+
data: {
|
|
150
|
+
content: `Successfully installed channel **${
|
|
151
|
+
channelListing?.name || templateName || channelId
|
|
152
|
+
}** and created channel \`${channelId}\`.`,
|
|
153
|
+
},
|
|
154
|
+
meta: { agentId: 'system' },
|
|
155
|
+
} as OpenBotEvent;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
yield {
|
|
158
|
+
type: 'action:channel:install:result',
|
|
159
|
+
data: {
|
|
160
|
+
success: false,
|
|
161
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
162
|
+
},
|
|
163
|
+
} as OpenBotEvent;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
65
167
|
builder.on('action:agent:install', async function* (event) {
|
|
66
168
|
try {
|
|
67
169
|
const {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolvePath } from '../../app/config.js';
|
|
5
|
+
|
|
6
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
7
|
+
'.png': 'image/png',
|
|
8
|
+
'.jpg': 'image/jpeg',
|
|
9
|
+
'.jpeg': 'image/jpeg',
|
|
10
|
+
'.gif': 'image/gif',
|
|
11
|
+
'.webp': 'image/webp',
|
|
12
|
+
'.svg': 'image/svg+xml',
|
|
13
|
+
'.ico': 'image/x-icon',
|
|
14
|
+
'.mp4': 'video/mp4',
|
|
15
|
+
'.webm': 'video/webm',
|
|
16
|
+
'.mov': 'video/quicktime',
|
|
17
|
+
'.mp3': 'audio/mpeg',
|
|
18
|
+
'.wav': 'audio/wav',
|
|
19
|
+
'.ogg': 'audio/ogg',
|
|
20
|
+
'.pdf': 'application/pdf',
|
|
21
|
+
'.json': 'application/json',
|
|
22
|
+
'.txt': 'text/plain',
|
|
23
|
+
'.html': 'text/html',
|
|
24
|
+
'.css': 'text/css',
|
|
25
|
+
'.js': 'text/javascript',
|
|
26
|
+
'.zip': 'application/zip',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function guessMimeType(filePath: string): string {
|
|
30
|
+
return MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve a relative path under a channel cwd; rejects directory escape. */
|
|
34
|
+
export function resolveChannelFile(baseCwd: string, relativePath: string): string {
|
|
35
|
+
const resolvedBase = resolvePath(baseCwd);
|
|
36
|
+
const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
37
|
+
const target = path.resolve(resolvedBase, normalized);
|
|
38
|
+
if (target !== resolvedBase && !target.startsWith(resolvedBase + path.sep)) {
|
|
39
|
+
throw new Error('Access denied: directory escape');
|
|
40
|
+
}
|
|
41
|
+
return target;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function statChannelFile(
|
|
45
|
+
baseCwd: string,
|
|
46
|
+
relativePath: string,
|
|
47
|
+
): Promise<{ abs: string; size: number }> {
|
|
48
|
+
const abs = resolveChannelFile(baseCwd, relativePath);
|
|
49
|
+
const stat = await fs.stat(abs);
|
|
50
|
+
if (!stat.isFile()) {
|
|
51
|
+
throw new Error('Not a file');
|
|
52
|
+
}
|
|
53
|
+
return { abs, size: stat.size };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function openChannelFileStream(abs: string) {
|
|
57
|
+
return createReadStream(abs);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildWorkspaceFileUrl(args: {
|
|
61
|
+
baseUrl: string;
|
|
62
|
+
channelId: string;
|
|
63
|
+
filePath: string;
|
|
64
|
+
}): string {
|
|
65
|
+
const base = args.baseUrl.replace(/\/$/, '');
|
|
66
|
+
const data = encodeURIComponent(JSON.stringify({ path: args.filePath }));
|
|
67
|
+
const channelId = encodeURIComponent(args.channelId);
|
|
68
|
+
return `${base}/api/state?channelId=${channelId}&type=${encodeURIComponent('action:storage:serve-file')}&data=${data}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getPublicBaseUrl(port: number, configPublicUrl?: string): string {
|
|
72
|
+
const fromConfig = configPublicUrl?.trim();
|
|
73
|
+
if (fromConfig) {
|
|
74
|
+
return fromConfig.replace(/\/$/, '');
|
|
75
|
+
}
|
|
76
|
+
const fromEnv = process.env.OPENBOT_PUBLIC_URL?.trim();
|
|
77
|
+
if (fromEnv) {
|
|
78
|
+
return fromEnv.replace(/\/$/, '');
|
|
79
|
+
}
|
|
80
|
+
return `http://localhost:${port}`;
|
|
81
|
+
}
|