@sulala/agent 0.1.6 → 0.1.8
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 +42 -27
- package/context/airtable.md +35 -0
- package/context/asana.md +37 -0
- package/context/bluesky.md +26 -91
- package/context/calendar.md +63 -0
- package/context/country-info.md +13 -0
- package/context/create-skill.md +128 -0
- package/context/discord.md +30 -0
- package/context/docs.md +29 -0
- package/context/drive.md +49 -0
- package/context/dropbox.md +39 -0
- package/context/facebook.md +47 -0
- package/context/fetch-form-api.md +16 -0
- package/context/figma.md +30 -0
- package/context/github.md +58 -0
- package/context/gmail.md +52 -0
- package/context/google.md +28 -0
- package/context/hellohub.md +29 -0
- package/context/jira.md +46 -0
- package/context/linear.md +40 -0
- package/context/notion.md +45 -0
- package/context/portal-integrations.md +42 -0
- package/context/post-to-x.md +50 -0
- package/context/sheets.md +47 -0
- package/context/slack.md +48 -0
- package/context/slides.md +35 -0
- package/context/stripe.md +38 -0
- package/context/tes.md +7 -0
- package/context/test.md +7 -0
- package/context/zoom.md +28 -0
- package/dist/agent/google/calendar.d.ts +2 -0
- package/dist/agent/google/calendar.d.ts.map +1 -0
- package/dist/agent/google/calendar.js +119 -0
- package/dist/agent/google/calendar.js.map +1 -0
- package/dist/agent/google/drive.d.ts +2 -0
- package/dist/agent/google/drive.d.ts.map +1 -0
- package/dist/agent/google/drive.js +51 -0
- package/dist/agent/google/drive.js.map +1 -0
- package/dist/agent/google/get-token.d.ts +7 -0
- package/dist/agent/google/get-token.d.ts.map +1 -0
- package/dist/agent/google/get-token.js +37 -0
- package/dist/agent/google/get-token.js.map +1 -0
- package/dist/agent/google/gmail.d.ts +2 -0
- package/dist/agent/google/gmail.d.ts.map +1 -0
- package/dist/agent/google/gmail.js +138 -0
- package/dist/agent/google/gmail.js.map +1 -0
- package/dist/agent/google/index.d.ts +2 -0
- package/dist/agent/google/index.d.ts.map +1 -0
- package/dist/agent/google/index.js +13 -0
- package/dist/agent/google/index.js.map +1 -0
- package/dist/agent/loop.d.ts +8 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +226 -40
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/memory.d.ts +21 -0
- package/dist/agent/memory.d.ts.map +1 -0
- package/dist/agent/memory.js +33 -0
- package/dist/agent/memory.js.map +1 -0
- package/dist/agent/pending-actions.d.ts +21 -0
- package/dist/agent/pending-actions.d.ts.map +1 -0
- package/dist/agent/pending-actions.js +65 -0
- package/dist/agent/pending-actions.js.map +1 -0
- package/dist/agent/pi-runner.d.ts +27 -0
- package/dist/agent/pi-runner.d.ts.map +1 -0
- package/dist/agent/pi-runner.js +300 -0
- package/dist/agent/pi-runner.js.map +1 -0
- package/dist/agent/skill-generate.d.ts +63 -0
- package/dist/agent/skill-generate.d.ts.map +1 -0
- package/dist/agent/skill-generate.js +128 -0
- package/dist/agent/skill-generate.js.map +1 -0
- package/dist/agent/skill-install.d.ts.map +1 -1
- package/dist/agent/skill-install.js +80 -31
- package/dist/agent/skill-install.js.map +1 -1
- package/dist/agent/skill-templates.d.ts +17 -0
- package/dist/agent/skill-templates.d.ts.map +1 -0
- package/dist/agent/skill-templates.js +26 -0
- package/dist/agent/skill-templates.js.map +1 -0
- package/dist/agent/skills-config.d.ts +24 -2
- package/dist/agent/skills-config.d.ts.map +1 -1
- package/dist/agent/skills-config.js +108 -9
- package/dist/agent/skills-config.js.map +1 -1
- package/dist/agent/skills-watcher.js +1 -1
- package/dist/agent/skills.d.ts +9 -3
- package/dist/agent/skills.d.ts.map +1 -1
- package/dist/agent/skills.js +104 -9
- package/dist/agent/skills.js.map +1 -1
- package/dist/agent/tools.d.ts +25 -3
- package/dist/agent/tools.d.ts.map +1 -1
- package/dist/agent/tools.integrations.test.d.ts +2 -0
- package/dist/agent/tools.integrations.test.d.ts.map +1 -0
- package/dist/agent/tools.integrations.test.js +269 -0
- package/dist/agent/tools.integrations.test.js.map +1 -0
- package/dist/agent/tools.js +692 -39
- package/dist/agent/tools.js.map +1 -1
- package/dist/ai/orchestrator.d.ts +6 -1
- package/dist/ai/orchestrator.d.ts.map +1 -1
- package/dist/ai/orchestrator.js +499 -212
- package/dist/ai/orchestrator.js.map +1 -1
- package/dist/ai/pricing.d.ts +6 -0
- package/dist/ai/pricing.d.ts.map +1 -0
- package/dist/ai/pricing.js +39 -0
- package/dist/ai/pricing.js.map +1 -0
- package/dist/channels/discord.d.ts +15 -0
- package/dist/channels/discord.d.ts.map +1 -0
- package/dist/channels/discord.js +55 -0
- package/dist/channels/discord.js.map +1 -0
- package/dist/channels/stripe.d.ts +15 -0
- package/dist/channels/stripe.d.ts.map +1 -0
- package/dist/channels/stripe.js +58 -0
- package/dist/channels/stripe.js.map +1 -0
- package/dist/channels/telegram.d.ts +60 -0
- package/dist/channels/telegram.d.ts.map +1 -0
- package/dist/channels/telegram.js +562 -0
- package/dist/channels/telegram.js.map +1 -0
- package/dist/cli.js +69 -11
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +91 -2
- package/dist/config.js.map +1 -1
- package/dist/db/index.d.ts +83 -0
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +174 -2
- package/dist/db/index.js.map +1 -1
- package/dist/db/schema.sql +35 -0
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +1224 -29
- package/dist/gateway/server.js.map +1 -1
- package/dist/index.js +149 -6
- package/dist/index.js.map +1 -1
- package/dist/ollama-setup.d.ts +27 -0
- package/dist/ollama-setup.d.ts.map +1 -0
- package/dist/ollama-setup.js +191 -0
- package/dist/ollama-setup.js.map +1 -0
- package/dist/onboard-env.d.ts +1 -1
- package/dist/onboard-env.d.ts.map +1 -1
- package/dist/onboard-env.js +3 -0
- package/dist/onboard-env.js.map +1 -1
- package/dist/onboard.d.ts +3 -1
- package/dist/onboard.d.ts.map +1 -1
- package/dist/onboard.js +9 -4
- package/dist/onboard.js.map +1 -1
- package/dist/plugins/index.d.ts +10 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +32 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/redact.d.ts +15 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +56 -0
- package/dist/redact.js.map +1 -0
- package/dist/scheduler/cron.d.ts +21 -0
- package/dist/scheduler/cron.d.ts.map +1 -1
- package/dist/scheduler/cron.js +60 -0
- package/dist/scheduler/cron.js.map +1 -1
- package/dist/system-capabilities.d.ts +11 -0
- package/dist/system-capabilities.d.ts.map +1 -0
- package/dist/system-capabilities.js +109 -0
- package/dist/system-capabilities.js.map +1 -0
- package/dist/types.d.ts +62 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher/index.d.ts +2 -0
- package/dist/watcher/index.d.ts.map +1 -1
- package/dist/watcher/index.js +31 -1
- package/dist/watcher/index.js.map +1 -1
- package/dist/workspace-automations.d.ts +16 -0
- package/dist/workspace-automations.d.ts.map +1 -0
- package/dist/workspace-automations.js +133 -0
- package/dist/workspace-automations.js.map +1 -0
- package/package.json +19 -3
- package/registry/bluesky.md +12 -89
- package/registry/skills-registry.json +6 -0
- package/src/db/schema.sql +35 -0
- package/src/index.ts +159 -6
package/dist/gateway/server.js
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { createServer } from 'http';
|
|
3
|
-
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
4
|
import { join, dirname } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
+
import { randomBytes } from 'crypto';
|
|
6
7
|
import { WebSocketServer } from 'ws';
|
|
7
8
|
import cors from 'cors';
|
|
8
|
-
import { config, getSulalaEnvPath } from '../config.js';
|
|
9
|
+
import { config, getSulalaEnvPath, getPortalGatewayBase, getEffectivePortalApiKey } from '../config.js';
|
|
9
10
|
import { readOnboardEnvKeys, writeOnboardEnvKeys, ONBOARD_ENV_WHITELIST, } from '../onboard-env.js';
|
|
10
|
-
import { initDb, getDb, log, insertTask, getFileStates, updateTaskStatus, setTaskPendingForRetry, getOrCreateAgentSession, getAgentSessionById, getAgentMessages, listAgentSessions, } from '../db/index.js';
|
|
11
|
+
import { initDb, getDb, log, insertTask, getFileStates, updateTaskStatus, setTaskPendingForRetry, getOrCreateAgentSession, getAgentSessionById, getAgentMessages, appendAgentMessage, updateAgentMessageToolResult, listAgentMemories, listAgentMemoryScopeKeys, listAgentSessions, listScheduledJobs, getScheduledJob, insertScheduledJob, updateScheduledJob, deleteScheduledJob, getTasksForJob, getChannelConfig, setChannelConfig, } from '../db/index.js';
|
|
12
|
+
import { scheduleCronById, unscheduleJob } from '../scheduler/cron.js';
|
|
13
|
+
import { getTelegramChannelState, setTelegramChannelConfig } from '../channels/telegram.js';
|
|
14
|
+
import { getDiscordChannelState, setDiscordChannelConfig } from '../channels/discord.js';
|
|
15
|
+
import { getStripeChannelState, setStripeChannelConfig } from '../channels/stripe.js';
|
|
16
|
+
import { reloadProviders } from '../ai/orchestrator.js';
|
|
11
17
|
import { runAgentTurn, runAgentTurnStream } from '../agent/loop.js';
|
|
18
|
+
import { runAgentTurnWithPi, isPiAvailable } from '../agent/pi-runner.js';
|
|
19
|
+
import { listTools, executeTool } from '../agent/tools.js';
|
|
12
20
|
import { listSkills, getAllRequiredBins } from '../agent/skills.js';
|
|
13
21
|
import { getRegistrySkills, getAvailableUpdates, installSkill, uninstallSkill, updateSkillsAll } from '../agent/skill-install.js';
|
|
14
|
-
import {
|
|
22
|
+
import { getTemplates } from '../agent/skill-templates.js';
|
|
23
|
+
import { generateSkillSpec, writeGeneratedSkill, WIZARD_APPS, WIZARD_TRIGGERS } from '../agent/skill-generate.js';
|
|
24
|
+
import { loadSkillsConfig, saveSkillsConfig, getConfigPath, setSkillEnabled, removeSkillEntry, migrateConfigNameKeysToSlug, getOnboardingComplete, setOnboardingComplete } from '../agent/skills-config.js';
|
|
25
|
+
import { redactSkillsConfig } from '../redact.js';
|
|
15
26
|
import { withSessionLock } from '../agent/session-queue.js';
|
|
27
|
+
import { listPendingActions, getPendingAction, getPendingActionForReplay, setPendingActionApproved, setPendingActionRejected, sanitizeArgsForDisplay, } from '../agent/pending-actions.js';
|
|
28
|
+
import { isOllamaReachable, startOllamaServeForApi, runOllamaInstall, setPullProgressCallback, pullOllamaModel } from '../ollama-setup.js';
|
|
29
|
+
import { getSystemCapabilities } from '../system-capabilities.js';
|
|
16
30
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
31
|
const projectRoot = join(__dirname, '..', '..');
|
|
18
32
|
const dashboardDist = join(projectRoot, 'dashboard', 'dist');
|
|
@@ -21,6 +35,25 @@ const rateLimitMap = new Map();
|
|
|
21
35
|
function rateLimitMiddleware(req, res, next) {
|
|
22
36
|
if (!config.rateLimitMax || config.rateLimitMax <= 0)
|
|
23
37
|
return next();
|
|
38
|
+
if (req.path === '/health')
|
|
39
|
+
return next();
|
|
40
|
+
// Don't rate-limit read-only or bootstrap endpoints the dashboard calls often
|
|
41
|
+
if (req.path.startsWith('/api/onboard'))
|
|
42
|
+
return next();
|
|
43
|
+
if (req.path === '/api/agent/pending-actions' && req.method === 'GET')
|
|
44
|
+
return next();
|
|
45
|
+
if (req.path === '/api/config' && req.method === 'GET')
|
|
46
|
+
return next();
|
|
47
|
+
if (req.path === '/api/integrations/connections' && req.method === 'GET')
|
|
48
|
+
return next();
|
|
49
|
+
if (req.path === '/api/integrations/connect' && req.method === 'POST')
|
|
50
|
+
return next();
|
|
51
|
+
if (req.path.startsWith('/api/integrations/connections/') && req.method === 'DELETE')
|
|
52
|
+
return next();
|
|
53
|
+
if (req.path === '/api/oauth/connect-url' && req.method === 'GET')
|
|
54
|
+
return next();
|
|
55
|
+
if (req.path === '/api/oauth/callback' && req.method === 'GET')
|
|
56
|
+
return next();
|
|
24
57
|
const ip = (req.ip || req.socket?.remoteAddress || 'unknown');
|
|
25
58
|
const now = Date.now();
|
|
26
59
|
let entry = rateLimitMap.get(ip);
|
|
@@ -40,11 +73,18 @@ export function createGateway(appMount = null) {
|
|
|
40
73
|
app.use(cors());
|
|
41
74
|
app.use(express.json());
|
|
42
75
|
app.use(rateLimitMiddleware);
|
|
76
|
+
try {
|
|
77
|
+
const skills = listSkills(config, { includeDisabled: true });
|
|
78
|
+
migrateConfigNameKeysToSlug(skills.map((s) => ({ name: s.name, slug: s.slug ?? s.name })));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* migration best-effort */
|
|
82
|
+
}
|
|
43
83
|
if (config.gatewayApiKey) {
|
|
44
84
|
app.use((req, res, next) => {
|
|
45
85
|
if (req.path === '/health')
|
|
46
86
|
return next();
|
|
47
|
-
if (req.path === '/onboard' || req.path.startsWith('/api/onboard'))
|
|
87
|
+
if (req.path === '/onboard' || req.path.startsWith('/api/onboard') || req.path.startsWith('/api/ollama'))
|
|
48
88
|
return next();
|
|
49
89
|
const key = req.headers['x-api-key'] || req.query.api_key;
|
|
50
90
|
if (key === config.gatewayApiKey)
|
|
@@ -93,9 +133,48 @@ export function createGateway(appMount = null) {
|
|
|
93
133
|
res.status(500).json({ error: e.message });
|
|
94
134
|
}
|
|
95
135
|
});
|
|
136
|
+
app.get('/api/system/capabilities', (_req, res) => {
|
|
137
|
+
try {
|
|
138
|
+
const caps = getSystemCapabilities();
|
|
139
|
+
res.json(caps);
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
log('gateway', 'error', e.message);
|
|
143
|
+
res.status(500).json({
|
|
144
|
+
storageFreeBytes: null,
|
|
145
|
+
isCloudOrContainer: false,
|
|
146
|
+
cloudReason: '',
|
|
147
|
+
ollamaSuitable: true,
|
|
148
|
+
ollamaSuitableReason: 'Check unavailable',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
});
|
|
96
152
|
app.get('/health', (_req, res) => {
|
|
97
153
|
res.json({ status: 'ok', service: 'sulala-gateway' });
|
|
98
154
|
});
|
|
155
|
+
/** Onboard: check if onboarding is complete (first-launch detection). */
|
|
156
|
+
app.get('/api/onboard/status', (_req, res) => {
|
|
157
|
+
try {
|
|
158
|
+
const complete = getOnboardingComplete();
|
|
159
|
+
res.json({ complete });
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
log('gateway', 'error', e.message);
|
|
163
|
+
res.status(500).json({ complete: false, error: e.message });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
/** Onboard: mark onboarding as complete. */
|
|
167
|
+
app.put('/api/onboard/complete', async (_req, res) => {
|
|
168
|
+
try {
|
|
169
|
+
setOnboardingComplete(true);
|
|
170
|
+
await reloadProviders();
|
|
171
|
+
res.json({ ok: true, complete: true });
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
log('gateway', 'error', e.message);
|
|
175
|
+
res.status(500).json({ error: e.message });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
99
178
|
/** Onboard: which API keys are set (values never returned). */
|
|
100
179
|
app.get('/api/onboard/env', (_req, res) => {
|
|
101
180
|
try {
|
|
@@ -108,8 +187,8 @@ export function createGateway(appMount = null) {
|
|
|
108
187
|
res.status(500).json({ error: e.message });
|
|
109
188
|
}
|
|
110
189
|
});
|
|
111
|
-
/** Onboard: save API keys to ~/.sulala/.env (whitelist only). */
|
|
112
|
-
app.put('/api/onboard/env', (req, res) => {
|
|
190
|
+
/** Onboard: save API keys to ~/.sulala/.env (whitelist only). Reload AI providers so new keys apply without restart. */
|
|
191
|
+
app.put('/api/onboard/env', async (req, res) => {
|
|
113
192
|
try {
|
|
114
193
|
const body = req.body;
|
|
115
194
|
if (!body || typeof body !== 'object') {
|
|
@@ -117,6 +196,7 @@ export function createGateway(appMount = null) {
|
|
|
117
196
|
return;
|
|
118
197
|
}
|
|
119
198
|
writeOnboardEnvKeys(body);
|
|
199
|
+
await reloadProviders();
|
|
120
200
|
res.json({ ok: true, keys: readOnboardEnvKeys() });
|
|
121
201
|
}
|
|
122
202
|
catch (e) {
|
|
@@ -124,6 +204,76 @@ export function createGateway(appMount = null) {
|
|
|
124
204
|
res.status(500).json({ error: e.message });
|
|
125
205
|
}
|
|
126
206
|
});
|
|
207
|
+
/** Ollama status and setup (for onboard and dashboard). */
|
|
208
|
+
app.get('/api/ollama/status', async (_req, res) => {
|
|
209
|
+
try {
|
|
210
|
+
const running = await isOllamaReachable();
|
|
211
|
+
const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
212
|
+
res.json({ running, baseUrl });
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
log('gateway', 'error', e.message);
|
|
216
|
+
res.status(500).json({ running: false, error: e.message });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
app.post('/api/ollama/start', async (_req, res) => {
|
|
220
|
+
try {
|
|
221
|
+
const result = await startOllamaServeForApi();
|
|
222
|
+
res.json(result);
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
log('gateway', 'error', e.message);
|
|
226
|
+
res.status(500).json({ started: false, error: e.message });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
app.post('/api/ollama/install', (_req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
runOllamaInstall();
|
|
232
|
+
res.json({ ok: true, message: 'Install started. Check your terminal or install from https://ollama.com' });
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
log('gateway', 'error', e.message);
|
|
236
|
+
res.status(500).json({ error: e.message });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
app.locals.ollamaPullState = { inProgress: false, model: '', lastLine: '', percent: 0 };
|
|
240
|
+
app.get('/api/ollama/pull-status', (_req, res) => {
|
|
241
|
+
res.json(app.locals.ollamaPullState ?? { inProgress: false, model: '', lastLine: '', percent: 0 });
|
|
242
|
+
});
|
|
243
|
+
setPullProgressCallback((model, line, percent) => {
|
|
244
|
+
const state = { inProgress: percent >= 0 && percent < 100, model, lastLine: line, percent };
|
|
245
|
+
app.locals.ollamaPullState = state;
|
|
246
|
+
const b = app.locals.wsBroadcast;
|
|
247
|
+
if (b)
|
|
248
|
+
b({ type: 'ollama_pull_progress', data: state });
|
|
249
|
+
});
|
|
250
|
+
app.post('/api/ollama/pull', (req, res) => {
|
|
251
|
+
try {
|
|
252
|
+
const { model } = req.body || {};
|
|
253
|
+
if (!model || typeof model !== 'string' || !model.trim()) {
|
|
254
|
+
res.status(400).json({ error: 'model required' });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
pullOllamaModel(model.trim());
|
|
258
|
+
res.json({ ok: true, model: model.trim(), message: 'Pull started. Check pull-status for progress.' });
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
log('gateway', 'error', e.message);
|
|
262
|
+
res.status(500).json({ error: e.message });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
/** Recommended Ollama models for onboarding (size, RAM, CPU/GPU). */
|
|
266
|
+
const RECOMMENDED_OLLAMA_MODELS = [
|
|
267
|
+
{ id: 'llama3.2', name: 'Llama 3.2', size: '~2GB', ram: '8GB', cpu: 'Any', gpu: 'Optional', description: 'Good balance of speed and quality' },
|
|
268
|
+
{ id: 'llama3.2:1b', name: 'Llama 3.2 1B', size: '~1.3GB', ram: '4GB', cpu: 'Any', gpu: 'None', description: 'Fast, low resource' },
|
|
269
|
+
{ id: 'mistral', name: 'Mistral 7B', size: '~4.1GB', ram: '8GB', cpu: '4+ cores', gpu: 'Recommended', description: 'Strong general model' },
|
|
270
|
+
{ id: 'codellama', name: 'Code Llama', size: '~3.8GB', ram: '8GB', cpu: '4+ cores', gpu: 'Recommended', description: 'Optimized for code' },
|
|
271
|
+
{ id: 'phi3', name: 'Phi-3 Mini', size: '~2.3GB', ram: '8GB', cpu: 'Any', gpu: 'Optional', description: 'Efficient small model' },
|
|
272
|
+
{ id: 'gemma2:2b', name: 'Gemma 2 2B', size: '~1.6GB', ram: '4GB', cpu: 'Any', gpu: 'None', description: 'Lightweight, capable' },
|
|
273
|
+
];
|
|
274
|
+
app.get('/api/onboard/recommended-models', (_req, res) => {
|
|
275
|
+
res.json({ models: RECOMMENDED_OLLAMA_MODELS });
|
|
276
|
+
});
|
|
127
277
|
/** Onboard: minimal HTML page to add API keys (no dashboard required). */
|
|
128
278
|
app.get('/onboard', (_req, res) => {
|
|
129
279
|
const keys = ONBOARD_ENV_WHITELIST.map((k) => `<div class="row"><label for="${k}">${k}</label><input type="password" id="${k}" name="${k}" placeholder="optional" autocomplete="off" /></div>`).join('\n');
|
|
@@ -137,22 +287,37 @@ export function createGateway(appMount = null) {
|
|
|
137
287
|
* { box-sizing: border-box; }
|
|
138
288
|
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
|
|
139
289
|
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
|
290
|
+
h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; color: #374151; }
|
|
140
291
|
p { color: #666; font-size: 0.875rem; margin-bottom: 1.25rem; }
|
|
141
292
|
.row { margin-bottom: 0.75rem; }
|
|
142
293
|
label { display: block; font-size: 0.75rem; font-weight: 600; margin-bottom: 0.25rem; color: #374151; }
|
|
143
294
|
input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; }
|
|
144
295
|
input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
145
|
-
button { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; cursor: pointer; margin-top: 0.5rem; }
|
|
296
|
+
button { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; cursor: pointer; margin-top: 0.5rem; margin-right: 0.5rem; }
|
|
146
297
|
button:hover { background: #1d4ed8; }
|
|
298
|
+
button.secondary { background: #6b7280; }
|
|
299
|
+
button.secondary:hover { background: #4b5563; }
|
|
147
300
|
.msg { margin-top: 1rem; font-size: 0.875rem; }
|
|
148
301
|
.msg.success { color: #059669; }
|
|
149
302
|
.msg.err { color: #dc2626; }
|
|
303
|
+
.ollama-ok { color: #059669; font-size: 0.875rem; }
|
|
150
304
|
a { color: #2563eb; }
|
|
305
|
+
#ollama-section { margin-bottom: 1rem; padding: 1rem; background: #f9fafb; border-radius: 8px; }
|
|
151
306
|
</style>
|
|
152
307
|
</head>
|
|
153
308
|
<body>
|
|
154
|
-
<
|
|
155
|
-
|
|
309
|
+
<div id="ollama-section">
|
|
310
|
+
<h2>Ollama (default AI)</h2>
|
|
311
|
+
<p id="ollama-status">Checking…</p>
|
|
312
|
+
<div id="ollama-actions" style="display: none;">
|
|
313
|
+
<button type="button" id="ollama-install">Install Ollama</button>
|
|
314
|
+
<button type="button" id="ollama-start">Start Ollama</button>
|
|
315
|
+
<button type="button" id="ollama-check" class="secondary">Check again</button>
|
|
316
|
+
<p style="margin-top: 0.75rem; font-size: 0.8rem;">Or install from <a href="https://ollama.com" target="_blank" rel="noopener">ollama.com</a> or run in terminal: <code>ollama serve</code></p>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
<h1>API keys (optional)</h1>
|
|
320
|
+
<p>Ollama works with no key. Add keys below to use OpenAI, Claude, Gemini, or OpenRouter. Saved to <code>~/.sulala/.env</code>. Restart the agent for changes.</p>
|
|
156
321
|
<form id="f">
|
|
157
322
|
${keys}
|
|
158
323
|
<button type="submit">Save</button>
|
|
@@ -162,6 +327,47 @@ export function createGateway(appMount = null) {
|
|
|
162
327
|
<script>
|
|
163
328
|
const form = document.getElementById('f');
|
|
164
329
|
const msg = document.getElementById('msg');
|
|
330
|
+
const ollamaStatus = document.getElementById('ollama-status');
|
|
331
|
+
const ollamaActions = document.getElementById('ollama-actions');
|
|
332
|
+
async function checkOllama() {
|
|
333
|
+
ollamaStatus.textContent = 'Checking…';
|
|
334
|
+
ollamaActions.style.display = 'none';
|
|
335
|
+
try {
|
|
336
|
+
const r = await fetch('/api/ollama/status');
|
|
337
|
+
const d = await r.json();
|
|
338
|
+
if (d.running) {
|
|
339
|
+
ollamaStatus.textContent = 'Ollama is running. You can use Chat.';
|
|
340
|
+
ollamaStatus.className = 'ollama-ok';
|
|
341
|
+
} else {
|
|
342
|
+
ollamaStatus.textContent = 'Ollama is not running.';
|
|
343
|
+
ollamaStatus.className = '';
|
|
344
|
+
ollamaActions.style.display = 'block';
|
|
345
|
+
}
|
|
346
|
+
} catch (e) {
|
|
347
|
+
ollamaStatus.textContent = 'Could not check Ollama.';
|
|
348
|
+
ollamaActions.style.display = 'block';
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
document.getElementById('ollama-install').onclick = async () => {
|
|
352
|
+
ollamaStatus.textContent = 'Starting install… (see terminal or install from ollama.com)';
|
|
353
|
+
try {
|
|
354
|
+
await fetch('/api/ollama/install', { method: 'POST' });
|
|
355
|
+
ollamaStatus.textContent = 'Install started. After it finishes, run: ollama serve';
|
|
356
|
+
ollamaActions.style.display = 'block';
|
|
357
|
+
} catch (e) { ollamaStatus.textContent = e.message; }
|
|
358
|
+
};
|
|
359
|
+
document.getElementById('ollama-start').onclick = async () => {
|
|
360
|
+
ollamaStatus.textContent = 'Starting Ollama…';
|
|
361
|
+
try {
|
|
362
|
+
const r = await fetch('/api/ollama/start', { method: 'POST' });
|
|
363
|
+
const d = await r.json();
|
|
364
|
+
if (d.started) ollamaStatus.textContent = 'Ollama starting… Check again in a few seconds.';
|
|
365
|
+
else ollamaStatus.textContent = 'Ollama not found. Install it first (button above or ollama.com).';
|
|
366
|
+
ollamaActions.style.display = 'block';
|
|
367
|
+
} catch (e) { ollamaStatus.textContent = e.message; }
|
|
368
|
+
};
|
|
369
|
+
document.getElementById('ollama-check').onclick = checkOllama;
|
|
370
|
+
checkOllama();
|
|
165
371
|
fetch('/api/onboard/env').then(r => r.json()).then(d => {
|
|
166
372
|
for (const [key, status] of Object.entries(d.keys || {})) {
|
|
167
373
|
const el = document.getElementById(key);
|
|
@@ -241,10 +447,377 @@ export function createGateway(appMount = null) {
|
|
|
241
447
|
res.status(500).json({ error: e.message });
|
|
242
448
|
}
|
|
243
449
|
});
|
|
450
|
+
app.get('/api/channels/telegram', (_req, res) => {
|
|
451
|
+
try {
|
|
452
|
+
const state = getTelegramChannelState();
|
|
453
|
+
res.json(state);
|
|
454
|
+
}
|
|
455
|
+
catch (e) {
|
|
456
|
+
log('gateway', 'error', e.message);
|
|
457
|
+
res.status(500).json({ error: e.message });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
app.put('/api/channels/telegram', async (req, res) => {
|
|
461
|
+
try {
|
|
462
|
+
const { enabled, botToken, dmPolicy, allowFrom, notificationChatId, defaultProvider, defaultModel } = req.body || {};
|
|
463
|
+
const result = await setTelegramChannelConfig({
|
|
464
|
+
enabled: typeof enabled === 'boolean' ? enabled : undefined,
|
|
465
|
+
botToken: botToken !== undefined ? botToken : undefined,
|
|
466
|
+
dmPolicy: typeof dmPolicy === 'string' ? dmPolicy : undefined,
|
|
467
|
+
allowFrom: Array.isArray(allowFrom) ? allowFrom : undefined,
|
|
468
|
+
notificationChatId: notificationChatId !== undefined ? (typeof notificationChatId === 'number' ? notificationChatId : parseInt(String(notificationChatId), 10)) : undefined,
|
|
469
|
+
defaultProvider: defaultProvider !== undefined ? (typeof defaultProvider === 'string' ? defaultProvider : null) : undefined,
|
|
470
|
+
defaultModel: defaultModel !== undefined ? (typeof defaultModel === 'string' ? defaultModel : null) : undefined,
|
|
471
|
+
});
|
|
472
|
+
if (!result.ok) {
|
|
473
|
+
res.status(400).json({ error: result.error || 'Failed to save' });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const state = getTelegramChannelState();
|
|
477
|
+
res.json(state);
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
log('gateway', 'error', e.message);
|
|
481
|
+
res.status(500).json({ error: e.message });
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
app.get('/api/channels/discord', (_req, res) => {
|
|
485
|
+
try {
|
|
486
|
+
const state = getDiscordChannelState();
|
|
487
|
+
res.json(state);
|
|
488
|
+
}
|
|
489
|
+
catch (e) {
|
|
490
|
+
log('gateway', 'error', e.message);
|
|
491
|
+
res.status(500).json({ error: e.message });
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
app.put('/api/channels/discord', async (req, res) => {
|
|
495
|
+
try {
|
|
496
|
+
const { botToken } = req.body || {};
|
|
497
|
+
const result = await setDiscordChannelConfig({
|
|
498
|
+
botToken: botToken !== undefined ? (typeof botToken === 'string' ? botToken : null) : undefined,
|
|
499
|
+
});
|
|
500
|
+
if (!result.ok) {
|
|
501
|
+
res.status(400).json({ error: result.error || 'Failed to save' });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const state = getDiscordChannelState();
|
|
505
|
+
res.json(state);
|
|
506
|
+
}
|
|
507
|
+
catch (e) {
|
|
508
|
+
log('gateway', 'error', e.message);
|
|
509
|
+
res.status(500).json({ error: e.message });
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
app.get('/api/channels/stripe', (_req, res) => {
|
|
513
|
+
try {
|
|
514
|
+
const state = getStripeChannelState();
|
|
515
|
+
res.json(state);
|
|
516
|
+
}
|
|
517
|
+
catch (e) {
|
|
518
|
+
log('gateway', 'error', e.message);
|
|
519
|
+
res.status(500).json({ error: e.message });
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
app.put('/api/channels/stripe', async (req, res) => {
|
|
523
|
+
try {
|
|
524
|
+
const { secretKey } = req.body || {};
|
|
525
|
+
const result = await setStripeChannelConfig({
|
|
526
|
+
secretKey: secretKey !== undefined ? (typeof secretKey === 'string' ? secretKey : null) : undefined,
|
|
527
|
+
});
|
|
528
|
+
if (!result.ok) {
|
|
529
|
+
res.status(400).json({ error: result.error || 'Failed to save' });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const state = getStripeChannelState();
|
|
533
|
+
res.json(state);
|
|
534
|
+
}
|
|
535
|
+
catch (e) {
|
|
536
|
+
log('gateway', 'error', e.message);
|
|
537
|
+
res.status(500).json({ error: e.message });
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
/** Returns connected integration provider ids (from Portal). When Portal is not configured, returns []. */
|
|
541
|
+
async function getConnectedIntegrationIds() {
|
|
542
|
+
const base = getPortalGatewayBase();
|
|
543
|
+
const key = getEffectivePortalApiKey();
|
|
544
|
+
if (!base || !key)
|
|
545
|
+
return [];
|
|
546
|
+
try {
|
|
547
|
+
const portalRes = await fetch(`${base}/connections`, {
|
|
548
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
549
|
+
});
|
|
550
|
+
if (!portalRes.ok)
|
|
551
|
+
return [];
|
|
552
|
+
const data = (await portalRes.json());
|
|
553
|
+
const raw = data.connections ?? [];
|
|
554
|
+
const ids = new Set();
|
|
555
|
+
for (const c of raw) {
|
|
556
|
+
const p = (c.provider ?? '').trim();
|
|
557
|
+
if (p)
|
|
558
|
+
ids.add(p);
|
|
559
|
+
}
|
|
560
|
+
return Array.from(ids);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return [];
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/** GET /api/integrations/connections — When Portal is configured, proxy list from Portal (dashboard can show connections like direct mode). */
|
|
567
|
+
app.get('/api/integrations/connections', async (_req, res) => {
|
|
568
|
+
try {
|
|
569
|
+
const base = getPortalGatewayBase();
|
|
570
|
+
const key = getEffectivePortalApiKey();
|
|
571
|
+
if (!base || !key) {
|
|
572
|
+
res.status(404).json({ error: 'Portal not configured', connections: [] });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const portalRes = await fetch(`${base}/connections`, {
|
|
576
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
577
|
+
});
|
|
578
|
+
if (!portalRes.ok) {
|
|
579
|
+
const text = await portalRes.text();
|
|
580
|
+
let err = `Portal: ${portalRes.status}`;
|
|
581
|
+
try {
|
|
582
|
+
const j = JSON.parse(text);
|
|
583
|
+
if (j.error)
|
|
584
|
+
err = j.error;
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
if (text)
|
|
588
|
+
err = text.slice(0, 200);
|
|
589
|
+
}
|
|
590
|
+
res.status(portalRes.status >= 500 ? 502 : portalRes.status).json({ error: err, connections: [] });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const data = (await portalRes.json());
|
|
594
|
+
const raw = data.connections ?? [];
|
|
595
|
+
const connections = raw.map((c) => {
|
|
596
|
+
const created = c.created_at;
|
|
597
|
+
const createdAt = typeof created === 'number' ? created : created ? new Date(created).getTime() : 0;
|
|
598
|
+
return {
|
|
599
|
+
id: c.connection_id ?? c.id ?? '',
|
|
600
|
+
provider: c.provider ?? '',
|
|
601
|
+
scopes: [],
|
|
602
|
+
createdAt,
|
|
603
|
+
updatedAt: undefined,
|
|
604
|
+
};
|
|
605
|
+
});
|
|
606
|
+
res.json({ connections });
|
|
607
|
+
}
|
|
608
|
+
catch (e) {
|
|
609
|
+
log('gateway', 'error', e.message);
|
|
610
|
+
res.status(500).json({ error: e.message, connections: [] });
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
/** POST /api/integrations/connect — When Portal configured, start OAuth via Portal. Body: { provider, redirect_success }. Returns { authUrl, connectionId }. */
|
|
614
|
+
app.post('/api/integrations/connect', async (req, res) => {
|
|
615
|
+
try {
|
|
616
|
+
const base = getPortalGatewayBase();
|
|
617
|
+
const key = getEffectivePortalApiKey();
|
|
618
|
+
if (!base || !key) {
|
|
619
|
+
res.status(404).json({ error: 'Portal not configured' });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const body = req.body;
|
|
623
|
+
const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
|
|
624
|
+
if (!provider) {
|
|
625
|
+
res.status(400).json({ error: 'provider required' });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const portalRes = await fetch(`${base}/connect`, {
|
|
629
|
+
method: 'POST',
|
|
630
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
631
|
+
body: JSON.stringify({ provider, redirect_success: body.redirect_success || undefined }),
|
|
632
|
+
});
|
|
633
|
+
const text = await portalRes.text();
|
|
634
|
+
if (!portalRes.ok) {
|
|
635
|
+
let err = `Portal: ${portalRes.status}`;
|
|
636
|
+
try {
|
|
637
|
+
const j = JSON.parse(text);
|
|
638
|
+
if (j.error)
|
|
639
|
+
err = j.error;
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
if (text)
|
|
643
|
+
err = text.slice(0, 200);
|
|
644
|
+
}
|
|
645
|
+
res.status(portalRes.status >= 500 ? 502 : portalRes.status).json({ error: err });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const data = JSON.parse(text);
|
|
649
|
+
if (!data.authUrl) {
|
|
650
|
+
res.status(502).json({ error: 'No authUrl from Portal' });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
res.json({ authUrl: data.authUrl, connectionId: data.connectionId ?? null });
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
log('gateway', 'error', e.message);
|
|
657
|
+
res.status(500).json({ error: e.message });
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
/** DELETE /api/integrations/connections/:id — When Portal configured, disconnect via Portal. */
|
|
661
|
+
app.delete('/api/integrations/connections/:id', async (req, res) => {
|
|
662
|
+
try {
|
|
663
|
+
const base = getPortalGatewayBase();
|
|
664
|
+
const key = getEffectivePortalApiKey();
|
|
665
|
+
if (!base || !key) {
|
|
666
|
+
res.status(404).json({ error: 'Portal not configured' });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const rawId = req.params?.id;
|
|
670
|
+
const id = Array.isArray(rawId) ? rawId[0] : rawId;
|
|
671
|
+
if (!id || typeof id !== 'string') {
|
|
672
|
+
res.status(400).json({ error: 'connection id required' });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const portalRes = await fetch(`${base}/connections/${encodeURIComponent(id)}`, {
|
|
676
|
+
method: 'DELETE',
|
|
677
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
678
|
+
});
|
|
679
|
+
if (!portalRes.ok) {
|
|
680
|
+
const text = await portalRes.text();
|
|
681
|
+
let err = `Portal: ${portalRes.status}`;
|
|
682
|
+
try {
|
|
683
|
+
const j = JSON.parse(text);
|
|
684
|
+
if (j.error)
|
|
685
|
+
err = j.error;
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
if (text)
|
|
689
|
+
err = text.slice(0, 200);
|
|
690
|
+
}
|
|
691
|
+
res.status(portalRes.status >= 500 ? 502 : portalRes.status).json({ error: err });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
res.json({ ok: true });
|
|
695
|
+
}
|
|
696
|
+
catch (e) {
|
|
697
|
+
log('gateway', 'error', e.message);
|
|
698
|
+
res.status(500).json({ error: e.message });
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
/** GET /api/oauth/connect-url — Build Portal "Connect with Sulala" URL for dashboard. Requires PORTAL_GATEWAY_URL, PORTAL_OAUTH_CLIENT_ID; redirect_uri = this gateway base + /api/oauth/callback. Optional return_to encoded in state for callback redirect. */
|
|
702
|
+
app.get('/api/oauth/connect-url', (req, res) => {
|
|
703
|
+
try {
|
|
704
|
+
const portalGateway = getPortalGatewayBase();
|
|
705
|
+
const portalBase = portalGateway ? portalGateway.replace(/\/api\/gateway$/i, '') : '';
|
|
706
|
+
const clientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
|
|
707
|
+
if (!portalBase || !clientId) {
|
|
708
|
+
res.status(503).json({
|
|
709
|
+
error: 'OAuth not configured',
|
|
710
|
+
hint: 'Set PORTAL_GATEWAY_URL and PORTAL_OAUTH_CLIENT_ID (and PORTAL_OAUTH_CLIENT_SECRET for callback). Register redirect_uri in the Portal.',
|
|
711
|
+
});
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const proto = req.headers['x-forwarded-proto'] || (req.secure ? 'https' : 'http');
|
|
715
|
+
const host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
|
716
|
+
const publicBase = (process.env.PUBLIC_URL || process.env.GATEWAY_PUBLIC_URL || '').trim() || `${proto}://${host}`;
|
|
717
|
+
const callbackUrl = `${publicBase.replace(/\/$/, '')}/api/oauth/callback`;
|
|
718
|
+
const return_to = req.query.return_to?.trim() || undefined;
|
|
719
|
+
const statePayload = JSON.stringify({ r: randomBytes(12).toString('base64url'), return_to });
|
|
720
|
+
const state = Buffer.from(statePayload, 'utf8').toString('base64url');
|
|
721
|
+
const url = `${portalBase}/connect?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
|
|
722
|
+
res.json({ url });
|
|
723
|
+
}
|
|
724
|
+
catch (e) {
|
|
725
|
+
log('gateway', 'error', e.message);
|
|
726
|
+
res.status(500).json({ error: e.message });
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
function parseOAuthReturnTo(req) {
|
|
730
|
+
try {
|
|
731
|
+
const stateRaw = req.query.state || '';
|
|
732
|
+
const statePayload = JSON.parse(Buffer.from(stateRaw, 'base64url').toString('utf8'));
|
|
733
|
+
if (statePayload && typeof statePayload.return_to === 'string' && statePayload.return_to.trim()) {
|
|
734
|
+
return statePayload.return_to.trim();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
/* state may be legacy random string */
|
|
739
|
+
}
|
|
740
|
+
return undefined;
|
|
741
|
+
}
|
|
742
|
+
/** GET /api/oauth/callback — Portal redirects here with code & state. Exchange for access token, save as PORTAL_API_KEY, redirect to dashboard. */
|
|
743
|
+
app.get('/api/oauth/callback', async (req, res) => {
|
|
744
|
+
const dashboardOrigin = (req.headers['x-forwarded-proto'] ? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}` : null) || (req.headers.origin || `${req.protocol}://${req.get('host')}`);
|
|
745
|
+
const return_to = parseOAuthReturnTo(req);
|
|
746
|
+
const returnFragment = return_to ? `&return_to=${encodeURIComponent(return_to)}` : '';
|
|
747
|
+
try {
|
|
748
|
+
const code = req.query.code || '';
|
|
749
|
+
const portalGateway = getPortalGatewayBase();
|
|
750
|
+
const portalBase = portalGateway ? portalGateway.replace(/\/api\/gateway$/i, '') : '';
|
|
751
|
+
const clientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
|
|
752
|
+
const clientSecret = (process.env.PORTAL_OAUTH_CLIENT_SECRET || '').trim();
|
|
753
|
+
if (!code || !portalBase || !clientId || !clientSecret) {
|
|
754
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=missing_config${returnFragment}`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const proto = req.headers['x-forwarded-proto'] || (req.secure ? 'https' : 'http');
|
|
758
|
+
const host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
|
759
|
+
const publicBase = (process.env.PUBLIC_URL || process.env.GATEWAY_PUBLIC_URL || '').trim() || `${proto}://${host}`;
|
|
760
|
+
const redirectUri = `${publicBase.replace(/\/$/, '')}/api/oauth/callback`;
|
|
761
|
+
const tokenRes = await fetch(`${portalBase}/api/oauth/token`, {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
headers: { 'Content-Type': 'application/json' },
|
|
764
|
+
body: JSON.stringify({
|
|
765
|
+
grant_type: 'authorization_code',
|
|
766
|
+
code,
|
|
767
|
+
client_id: clientId,
|
|
768
|
+
client_secret: clientSecret,
|
|
769
|
+
redirect_uri: redirectUri,
|
|
770
|
+
}),
|
|
771
|
+
});
|
|
772
|
+
const tokenText = await tokenRes.text();
|
|
773
|
+
if (!tokenRes.ok) {
|
|
774
|
+
log('gateway', 'error', `OAuth token exchange failed: ${tokenRes.status} ${tokenText}`);
|
|
775
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=exchange_failed${returnFragment}`);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
let tokenData;
|
|
779
|
+
try {
|
|
780
|
+
tokenData = JSON.parse(tokenText);
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=invalid_response${returnFragment}`);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const accessToken = tokenData.access_token?.trim();
|
|
787
|
+
if (!accessToken) {
|
|
788
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=no_token${returnFragment}`);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
writeOnboardEnvKeys({ PORTAL_API_KEY: accessToken });
|
|
792
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=success${returnFragment}`);
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
log('gateway', 'error', e.message);
|
|
796
|
+
const dashboardOrigin = (req.headers.origin || (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host'] ? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}` : null) || `${req.protocol}://${req.get('host')}`);
|
|
797
|
+
const return_to = parseOAuthReturnTo(req);
|
|
798
|
+
const returnFragment = return_to ? `&return_to=${encodeURIComponent(return_to)}` : '';
|
|
799
|
+
res.redirect(302, `${dashboardOrigin || '/'}/?page=integrations&oauth=error&message=server_error${returnFragment}`);
|
|
800
|
+
}
|
|
801
|
+
});
|
|
244
802
|
app.get('/api/config', (_req, res) => {
|
|
245
803
|
try {
|
|
804
|
+
const portalUrl = getPortalGatewayBase();
|
|
805
|
+
const portalKey = getEffectivePortalApiKey();
|
|
806
|
+
const portalSet = !!(portalUrl && portalKey);
|
|
807
|
+
const directSet = !!config.integrationsUrl?.trim();
|
|
808
|
+
const integrationsMode = portalSet ? 'portal' : directSet ? 'direct' : null;
|
|
809
|
+
const portalOAuthClientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
|
|
246
810
|
res.json({
|
|
247
811
|
watchFolders: config.watchFolders || [],
|
|
812
|
+
agentUsePi: config.agentUsePi,
|
|
813
|
+
piAvailable: isPiAvailable(),
|
|
814
|
+
/** When set, dashboard can use this for Integrations page instead of VITE_INTEGRATIONS_URL. */
|
|
815
|
+
integrationsUrl: config.integrationsUrl || null,
|
|
816
|
+
/** 'portal' = agent uses Portal for connections; 'direct' = agent uses INTEGRATIONS_URL; null = neither. */
|
|
817
|
+
integrationsMode,
|
|
818
|
+
portalGatewayUrl: portalUrl || config.portalGatewayUrl || null,
|
|
819
|
+
/** When set, dashboard can show "Connect with Sulala (OAuth)" and use /api/oauth/connect-url. */
|
|
820
|
+
portalOAuthConnectAvailable: !!(portalUrl && portalOAuthClientId),
|
|
248
821
|
aiProviders: [
|
|
249
822
|
{ id: 'openai', label: 'OpenAI', defaultModel: process.env.AI_OPENAI_DEFAULT_MODEL || 'gpt-4o-mini' },
|
|
250
823
|
{ id: 'openrouter', label: 'OpenRouter', defaultModel: process.env.AI_OPENROUTER_DEFAULT_MODEL || 'openai/gpt-4o-mini' },
|
|
@@ -259,6 +832,51 @@ export function createGateway(appMount = null) {
|
|
|
259
832
|
res.status(500).json({ error: e.message });
|
|
260
833
|
}
|
|
261
834
|
});
|
|
835
|
+
const JOB_DEFAULT_KEY = 'job_default';
|
|
836
|
+
app.get('/api/settings/job-default', (_req, res) => {
|
|
837
|
+
try {
|
|
838
|
+
const raw = getChannelConfig(JOB_DEFAULT_KEY);
|
|
839
|
+
if (!raw?.trim()) {
|
|
840
|
+
res.json({ defaultProvider: null, defaultModel: null });
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const o = JSON.parse(raw);
|
|
844
|
+
const defaultProvider = typeof o.defaultProvider === 'string' ? o.defaultProvider.trim() || null : null;
|
|
845
|
+
const defaultModel = typeof o.defaultModel === 'string' ? o.defaultModel.trim() || null : null;
|
|
846
|
+
res.json({ defaultProvider, defaultModel });
|
|
847
|
+
}
|
|
848
|
+
catch (e) {
|
|
849
|
+
log('gateway', 'error', e.message);
|
|
850
|
+
res.status(500).json({ error: e.message });
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
app.put('/api/settings/job-default', (req, res) => {
|
|
854
|
+
try {
|
|
855
|
+
const { defaultProvider, defaultModel } = req.body || {};
|
|
856
|
+
const provider = defaultProvider !== undefined ? (typeof defaultProvider === 'string' ? defaultProvider.trim() || null : null) : undefined;
|
|
857
|
+
const model = defaultModel !== undefined ? (typeof defaultModel === 'string' ? defaultModel.trim() || null : null) : undefined;
|
|
858
|
+
const current = getChannelConfig(JOB_DEFAULT_KEY);
|
|
859
|
+
let next = {};
|
|
860
|
+
if (current?.trim()) {
|
|
861
|
+
try {
|
|
862
|
+
next = JSON.parse(current);
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
// ignore
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (provider !== undefined)
|
|
869
|
+
next.defaultProvider = provider;
|
|
870
|
+
if (model !== undefined)
|
|
871
|
+
next.defaultModel = model;
|
|
872
|
+
setChannelConfig(JOB_DEFAULT_KEY, JSON.stringify(next));
|
|
873
|
+
res.json({ defaultProvider: next.defaultProvider ?? null, defaultModel: next.defaultModel ?? null });
|
|
874
|
+
}
|
|
875
|
+
catch (e) {
|
|
876
|
+
log('gateway', 'error', e.message);
|
|
877
|
+
res.status(500).json({ error: e.message });
|
|
878
|
+
}
|
|
879
|
+
});
|
|
262
880
|
app.get('/api/agent/skills/required-bins', (_req, res) => {
|
|
263
881
|
try {
|
|
264
882
|
const bins = getAllRequiredBins(config);
|
|
@@ -282,7 +900,7 @@ export function createGateway(appMount = null) {
|
|
|
282
900
|
app.get('/api/agent/skills/config', (_req, res) => {
|
|
283
901
|
try {
|
|
284
902
|
const cfg = loadSkillsConfig();
|
|
285
|
-
res.json({ skills: cfg, configPath: getConfigPath() });
|
|
903
|
+
res.json({ skills: redactSkillsConfig(cfg), configPath: getConfigPath() });
|
|
286
904
|
}
|
|
287
905
|
catch (e) {
|
|
288
906
|
log('gateway', 'error', e.message);
|
|
@@ -294,7 +912,22 @@ export function createGateway(appMount = null) {
|
|
|
294
912
|
const { skills } = req.body || {};
|
|
295
913
|
if (skills && typeof skills === 'object')
|
|
296
914
|
saveSkillsConfig(skills);
|
|
297
|
-
res.json({ skills: loadSkillsConfig(), configPath: getConfigPath() });
|
|
915
|
+
res.json({ skills: redactSkillsConfig(loadSkillsConfig()), configPath: getConfigPath() });
|
|
916
|
+
}
|
|
917
|
+
catch (e) {
|
|
918
|
+
log('gateway', 'error', e.message);
|
|
919
|
+
res.status(500).json({ error: e.message });
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
app.post('/api/agent/skills/config/remove', (req, res) => {
|
|
923
|
+
try {
|
|
924
|
+
const { slug } = req.body || {};
|
|
925
|
+
if (!slug || typeof slug !== 'string') {
|
|
926
|
+
res.status(400).json({ error: 'slug required' });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
removeSkillEntry(slug);
|
|
930
|
+
res.json({ removed: slug, skills: redactSkillsConfig(loadSkillsConfig()), configPath: getConfigPath() });
|
|
298
931
|
}
|
|
299
932
|
catch (e) {
|
|
300
933
|
log('gateway', 'error', e.message);
|
|
@@ -311,8 +944,17 @@ export function createGateway(appMount = null) {
|
|
|
311
944
|
res.status(500).json({ error: e.message });
|
|
312
945
|
}
|
|
313
946
|
});
|
|
314
|
-
app.get('/api/agent/skills/registry', async (
|
|
947
|
+
app.get('/api/agent/skills/registry', async (req, res) => {
|
|
315
948
|
try {
|
|
949
|
+
const url = req.query.url?.trim();
|
|
950
|
+
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
|
|
951
|
+
const resp = await fetch(url);
|
|
952
|
+
if (!resp.ok)
|
|
953
|
+
throw new Error(`Registry fetch failed: ${resp.status}`);
|
|
954
|
+
const data = (await resp.json());
|
|
955
|
+
res.json({ skills: data.skills ?? [] });
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
316
958
|
const skills = await getRegistrySkills();
|
|
317
959
|
res.json({ skills });
|
|
318
960
|
}
|
|
@@ -374,9 +1016,150 @@ export function createGateway(appMount = null) {
|
|
|
374
1016
|
res.status(500).json({ error: e.message });
|
|
375
1017
|
}
|
|
376
1018
|
});
|
|
377
|
-
/**
|
|
1019
|
+
/** Skill Wizard: generate spec and optionally write skill to workspace. */
|
|
1020
|
+
app.get('/api/agent/skills/wizard-apps', (_req, res) => {
|
|
1021
|
+
res.json({ apps: WIZARD_APPS, triggers: WIZARD_TRIGGERS });
|
|
1022
|
+
});
|
|
1023
|
+
app.get('/api/agent/skills/templates', async (_req, res) => {
|
|
1024
|
+
try {
|
|
1025
|
+
const registrySkills = await getRegistrySkills();
|
|
1026
|
+
const templates = getTemplates(registrySkills);
|
|
1027
|
+
res.json({ templates });
|
|
1028
|
+
}
|
|
1029
|
+
catch (e) {
|
|
1030
|
+
log('gateway', 'error', e.message);
|
|
1031
|
+
res.status(500).json({ error: e.message });
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
app.post('/api/agent/skills/generate', (req, res) => {
|
|
1035
|
+
try {
|
|
1036
|
+
const { goal = '', app: appId = 'other', trigger: triggerId = 'manual', write } = req.body || {};
|
|
1037
|
+
const spec = generateSkillSpec(typeof goal === 'string' ? goal : '', typeof appId === 'string' ? appId : 'other', typeof triggerId === 'string' ? triggerId : 'manual');
|
|
1038
|
+
if (write) {
|
|
1039
|
+
const { path: filePath, slug } = writeGeneratedSkill(config, spec);
|
|
1040
|
+
res.status(201).json({ spec, path: filePath, slug, written: true });
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
res.json({ spec, written: false });
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
catch (e) {
|
|
1047
|
+
log('gateway', 'error', e.message);
|
|
1048
|
+
res.status(500).json({ error: e.message });
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
/** Execution preview: list pending tool actions awaiting approval. */
|
|
1052
|
+
app.get('/api/agent/pending-actions', (req, res) => {
|
|
1053
|
+
try {
|
|
1054
|
+
const sessionId = typeof req.query.session_id === 'string' ? req.query.session_id : undefined;
|
|
1055
|
+
const list = listPendingActions(sessionId).map((a) => ({
|
|
1056
|
+
...a,
|
|
1057
|
+
args: sanitizeArgsForDisplay(a.args),
|
|
1058
|
+
}));
|
|
1059
|
+
res.json({ pendingActions: list });
|
|
1060
|
+
}
|
|
1061
|
+
catch (e) {
|
|
1062
|
+
log('gateway', 'error', e.message);
|
|
1063
|
+
res.status(500).json({ error: e.message });
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
app.post('/api/agent/pending-actions/:id/approve', async (req, res) => {
|
|
1067
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1068
|
+
if (!id) {
|
|
1069
|
+
res.status(400).json({ error: 'Pending action id required' });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
const pending = getPendingActionForReplay(id);
|
|
1074
|
+
if (!pending) {
|
|
1075
|
+
res.status(404).json({ error: 'Pending action not found or already handled' });
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
let result;
|
|
1079
|
+
try {
|
|
1080
|
+
result = await executeTool(pending.toolName, pending.args, {
|
|
1081
|
+
sessionId: pending.sessionId,
|
|
1082
|
+
toolCallId: pending.toolCallId,
|
|
1083
|
+
skipApproval: true,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
catch (err) {
|
|
1087
|
+
result = { error: err instanceof Error ? err.message : String(err) };
|
|
1088
|
+
}
|
|
1089
|
+
const resultContent = typeof result === 'string' ? result : JSON.stringify(result);
|
|
1090
|
+
const updated = updateAgentMessageToolResult(pending.sessionId, pending.toolCallId, resultContent);
|
|
1091
|
+
if (!updated) {
|
|
1092
|
+
appendAgentMessage({
|
|
1093
|
+
session_id: pending.sessionId,
|
|
1094
|
+
role: 'tool',
|
|
1095
|
+
tool_call_id: pending.toolCallId,
|
|
1096
|
+
name: pending.toolName,
|
|
1097
|
+
content: resultContent,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
setPendingActionApproved(id, result);
|
|
1101
|
+
res.json({ ok: true, result });
|
|
1102
|
+
}
|
|
1103
|
+
catch (e) {
|
|
1104
|
+
log('gateway', 'error', e.message);
|
|
1105
|
+
res.status(500).json({ error: e.message });
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
app.post('/api/agent/pending-actions/:id/reject', (req, res) => {
|
|
1109
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1110
|
+
if (!id) {
|
|
1111
|
+
res.status(400).json({ error: 'Pending action id required' });
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
try {
|
|
1115
|
+
const pending = getPendingAction(id);
|
|
1116
|
+
if (!pending || pending.status !== 'pending') {
|
|
1117
|
+
res.status(404).json({ error: 'Pending action not found or already handled' });
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
setPendingActionRejected(id);
|
|
1121
|
+
const rejectedContent = JSON.stringify({ error: 'User rejected this action.' });
|
|
1122
|
+
const updated = updateAgentMessageToolResult(pending.sessionId, pending.toolCallId, rejectedContent);
|
|
1123
|
+
if (!updated) {
|
|
1124
|
+
appendAgentMessage({
|
|
1125
|
+
session_id: pending.sessionId,
|
|
1126
|
+
role: 'tool',
|
|
1127
|
+
tool_call_id: pending.toolCallId,
|
|
1128
|
+
name: pending.toolName,
|
|
1129
|
+
content: rejectedContent,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
res.json({ ok: true });
|
|
1133
|
+
}
|
|
1134
|
+
catch (e) {
|
|
1135
|
+
log('gateway', 'error', e.message);
|
|
1136
|
+
res.status(500).json({ error: e.message });
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
/** List models for a provider. Ollama: local /api/tags; OpenRouter: proxies OpenRouter API. */
|
|
378
1140
|
app.get('/api/agent/models', async (req, res) => {
|
|
379
1141
|
const provider = req.query.provider || '';
|
|
1142
|
+
if (provider === 'ollama') {
|
|
1143
|
+
try {
|
|
1144
|
+
const base = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
1145
|
+
const r = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
|
1146
|
+
if (!r.ok) {
|
|
1147
|
+
res.json({ models: [] });
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const data = (await r.json());
|
|
1151
|
+
const toolsWhitelist = /^(qwen3|qwen3\.5|qwen3-vl|qwen3-next|deepseek-r1|deepseek-v3|gpt-oss|glm-4\.7-flash|nemotron-3-nano|magistral|llama3|hermes3|mistral|gemma2|codellama|solar|qwen2|wizardlm2|neural-chat|starling-lm)(:|$)/i;
|
|
1152
|
+
const models = (data.models || [])
|
|
1153
|
+
.filter((m) => toolsWhitelist.test(m.name))
|
|
1154
|
+
.filter((m) => !/:1b$|:1\.5b$|:0\.6b$|:2b$/i.test(m.name))
|
|
1155
|
+
.map((m) => ({ id: m.name, name: m.name }));
|
|
1156
|
+
res.json({ models });
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
res.json({ models: [] });
|
|
1160
|
+
}
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
380
1163
|
if (provider !== 'openrouter') {
|
|
381
1164
|
res.json({ models: [] });
|
|
382
1165
|
return;
|
|
@@ -416,6 +1199,33 @@ export function createGateway(appMount = null) {
|
|
|
416
1199
|
res.status(500).json({ error: e.message });
|
|
417
1200
|
}
|
|
418
1201
|
});
|
|
1202
|
+
/** POST /api/tools/invoke — direct tool execution. Same policy as agent run. */
|
|
1203
|
+
app.post('/api/tools/invoke', async (req, res) => {
|
|
1204
|
+
try {
|
|
1205
|
+
const body = (req.body || {});
|
|
1206
|
+
const toolName = typeof body.tool === 'string' ? body.tool.trim() : '';
|
|
1207
|
+
if (!toolName) {
|
|
1208
|
+
res.status(400).json({ error: 'body.tool (string) required' });
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
const args = body.args != null && typeof body.args === 'object' && !Array.isArray(body.args)
|
|
1212
|
+
? body.args
|
|
1213
|
+
: {};
|
|
1214
|
+
const allowed = listTools();
|
|
1215
|
+
const tool = allowed.find((t) => t.name === toolName);
|
|
1216
|
+
if (!tool) {
|
|
1217
|
+
res.status(404).json({ error: `Tool "${toolName}" not found or not allowed` });
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
const result = await executeTool(toolName, args);
|
|
1221
|
+
res.json({ ok: true, result });
|
|
1222
|
+
}
|
|
1223
|
+
catch (e) {
|
|
1224
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1225
|
+
log('gateway', 'error', msg, { stack: e instanceof Error ? e.stack : undefined });
|
|
1226
|
+
res.status(500).json({ error: msg });
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
419
1229
|
// --- Agent runner (sessions + tool loop) ---
|
|
420
1230
|
app.get('/api/agent/sessions', (req, res) => {
|
|
421
1231
|
try {
|
|
@@ -428,6 +1238,42 @@ export function createGateway(appMount = null) {
|
|
|
428
1238
|
res.status(500).json({ error: e.message });
|
|
429
1239
|
}
|
|
430
1240
|
});
|
|
1241
|
+
/** GET /api/agent/memory/scope-keys — distinct scope_key per scope (for Settings > Memory dropdowns). */
|
|
1242
|
+
app.get('/api/agent/memory/scope-keys', (_req, res) => {
|
|
1243
|
+
try {
|
|
1244
|
+
const keys = listAgentMemoryScopeKeys();
|
|
1245
|
+
res.json(keys);
|
|
1246
|
+
}
|
|
1247
|
+
catch (e) {
|
|
1248
|
+
log('gateway', 'error', e.message);
|
|
1249
|
+
res.status(500).json({ error: e.message });
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
/** GET /api/agent/memory — list memory entries. Query: scope (session|shared), scope_key, limit (default 100). */
|
|
1253
|
+
app.get('/api/agent/memory', (req, res) => {
|
|
1254
|
+
try {
|
|
1255
|
+
const scope = req.query.scope === 'shared' ? 'shared' : 'session';
|
|
1256
|
+
const scopeKey = typeof req.query.scope_key === 'string' ? req.query.scope_key.trim() : '';
|
|
1257
|
+
if (!scopeKey) {
|
|
1258
|
+
res.status(400).json({ error: 'scope_key is required' });
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
|
|
1262
|
+
const rows = listAgentMemories(scope, scopeKey, limit);
|
|
1263
|
+
const entries = rows.map((r) => ({
|
|
1264
|
+
id: r.id,
|
|
1265
|
+
scope: r.scope,
|
|
1266
|
+
scope_key: r.scope_key,
|
|
1267
|
+
content: r.content,
|
|
1268
|
+
created_at: r.created_at,
|
|
1269
|
+
}));
|
|
1270
|
+
res.json({ entries });
|
|
1271
|
+
}
|
|
1272
|
+
catch (e) {
|
|
1273
|
+
log('gateway', 'error', e.message);
|
|
1274
|
+
res.status(500).json({ error: e.message });
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
431
1277
|
app.post('/api/agent/sessions', (req, res) => {
|
|
432
1278
|
try {
|
|
433
1279
|
const sessionKey = req.body?.session_key || `default_${Date.now()}`;
|
|
@@ -463,6 +1309,17 @@ export function createGateway(appMount = null) {
|
|
|
463
1309
|
msg.tool_calls = [];
|
|
464
1310
|
}
|
|
465
1311
|
}
|
|
1312
|
+
if (r.usage) {
|
|
1313
|
+
try {
|
|
1314
|
+
msg.usage = JSON.parse(r.usage);
|
|
1315
|
+
}
|
|
1316
|
+
catch {
|
|
1317
|
+
// ignore
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
const costUsd = r.cost_usd;
|
|
1321
|
+
if (costUsd != null)
|
|
1322
|
+
msg.cost_usd = costUsd;
|
|
466
1323
|
return msg;
|
|
467
1324
|
});
|
|
468
1325
|
res.json({ ...session, messages });
|
|
@@ -485,19 +1342,55 @@ export function createGateway(appMount = null) {
|
|
|
485
1342
|
}
|
|
486
1343
|
const controller = new AbortController();
|
|
487
1344
|
req.on('close', () => controller.abort());
|
|
488
|
-
const { message, system_prompt, provider, model, max_tokens, timeout_ms } = req.body || {};
|
|
1345
|
+
const { message, system_prompt, provider, model, max_tokens, timeout_ms, use_pi, required_integrations, attachment_urls } = req.body || {};
|
|
1346
|
+
const urls = Array.isArray(attachment_urls) ? attachment_urls.filter((u) => typeof u === 'string' && u.trim()) : [];
|
|
1347
|
+
const userMessageText = typeof message === 'string' ? message : '';
|
|
1348
|
+
const fullUserMessage = urls.length > 0 ? `${userMessageText}\n\n[Attached media (use these URLs when posting images, e.g. Facebook photo post): ${urls.join(', ')}]` : userMessageText;
|
|
1349
|
+
const required = Array.isArray(required_integrations)
|
|
1350
|
+
? required_integrations.filter((k) => typeof k === 'string' && k.trim())
|
|
1351
|
+
: [];
|
|
1352
|
+
if (required.length > 0) {
|
|
1353
|
+
const connected = await getConnectedIntegrationIds();
|
|
1354
|
+
const missing = required.filter((k) => !connected.includes(k));
|
|
1355
|
+
if (missing.length > 0) {
|
|
1356
|
+
res.status(422).json({
|
|
1357
|
+
type: 'missing_integrations',
|
|
1358
|
+
error: 'Missing required integrations',
|
|
1359
|
+
missing,
|
|
1360
|
+
});
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
489
1364
|
const timeoutMs = typeof timeout_ms === 'number' ? timeout_ms : config.agentTimeoutMs || 0;
|
|
1365
|
+
const usePi = config.agentUsePi || use_pi === true || use_pi === '1';
|
|
1366
|
+
if (usePi && !isPiAvailable()) {
|
|
1367
|
+
res.status(503).json({
|
|
1368
|
+
error: 'Pi runner requested but not available. Install optional deps: npm install @mariozechner/pi-agent-core @mariozechner/pi-ai @mariozechner/pi-coding-agent',
|
|
1369
|
+
});
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
490
1372
|
try {
|
|
491
|
-
const result = await withSessionLock(id, () =>
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1373
|
+
const result = await withSessionLock(id, () => usePi
|
|
1374
|
+
? runAgentTurnWithPi({
|
|
1375
|
+
sessionId: id,
|
|
1376
|
+
userMessage: fullUserMessage || null,
|
|
1377
|
+
systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
|
|
1378
|
+
provider: typeof provider === 'string' ? provider : undefined,
|
|
1379
|
+
model: typeof model === 'string' ? model : undefined,
|
|
1380
|
+
max_tokens: typeof max_tokens === 'number' ? max_tokens : undefined,
|
|
1381
|
+
timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
|
|
1382
|
+
signal: controller.signal,
|
|
1383
|
+
})
|
|
1384
|
+
: runAgentTurn({
|
|
1385
|
+
sessionId: id,
|
|
1386
|
+
userMessage: fullUserMessage || null,
|
|
1387
|
+
systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
|
|
1388
|
+
provider: typeof provider === 'string' ? provider : undefined,
|
|
1389
|
+
model: typeof model === 'string' ? model : undefined,
|
|
1390
|
+
max_tokens: typeof max_tokens === 'number' ? max_tokens : undefined,
|
|
1391
|
+
timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
|
|
1392
|
+
signal: controller.signal,
|
|
1393
|
+
}));
|
|
501
1394
|
res.json(result);
|
|
502
1395
|
}
|
|
503
1396
|
catch (e) {
|
|
@@ -506,6 +1399,7 @@ export function createGateway(appMount = null) {
|
|
|
506
1399
|
res.status(499).json({ error: 'Client closed request or run timed out' });
|
|
507
1400
|
return;
|
|
508
1401
|
}
|
|
1402
|
+
console.error('[gateway] POST /api/agent/sessions/:id/messages error:', err.message, err.stack);
|
|
509
1403
|
log('gateway', 'error', err.message, { stack: err.stack });
|
|
510
1404
|
res.status(500).json({ error: err.message });
|
|
511
1405
|
}
|
|
@@ -521,6 +1415,21 @@ export function createGateway(appMount = null) {
|
|
|
521
1415
|
res.status(404).json({ error: 'Session not found' });
|
|
522
1416
|
return;
|
|
523
1417
|
}
|
|
1418
|
+
const bodyRequired = Array.isArray(req.body?.required_integrations)
|
|
1419
|
+
? (req.body.required_integrations.filter((k) => typeof k === 'string' && k.trim()))
|
|
1420
|
+
: [];
|
|
1421
|
+
if (bodyRequired.length > 0) {
|
|
1422
|
+
const connected = await getConnectedIntegrationIds();
|
|
1423
|
+
const missing = bodyRequired.filter((k) => !connected.includes(k));
|
|
1424
|
+
if (missing.length > 0) {
|
|
1425
|
+
res.status(422).json({
|
|
1426
|
+
type: 'missing_integrations',
|
|
1427
|
+
error: 'Missing required integrations',
|
|
1428
|
+
missing,
|
|
1429
|
+
});
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
524
1433
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
525
1434
|
res.setHeader('Cache-Control', 'no-cache');
|
|
526
1435
|
res.setHeader('Connection', 'keep-alive');
|
|
@@ -538,12 +1447,17 @@ export function createGateway(appMount = null) {
|
|
|
538
1447
|
send('start', { runId });
|
|
539
1448
|
const controller = new AbortController();
|
|
540
1449
|
req.on('close', () => controller.abort());
|
|
541
|
-
const { message, system_prompt, provider, model, max_tokens, timeout_ms } = req.body || {};
|
|
1450
|
+
const { message, continue: continueAfterApproval, system_prompt, provider, model, max_tokens, timeout_ms, attachment_urls: streamAttachmentUrls } = req.body || {};
|
|
542
1451
|
const timeoutMs = typeof timeout_ms === 'number' ? timeout_ms : config.agentTimeoutMs || 0;
|
|
1452
|
+
const isContinue = continueAfterApproval === true || continueAfterApproval === 'true';
|
|
1453
|
+
const streamUrls = Array.isArray(streamAttachmentUrls) ? streamAttachmentUrls.filter((u) => typeof u === 'string' && u.trim()) : [];
|
|
1454
|
+
const streamMsg = typeof message === 'string' ? message : '';
|
|
1455
|
+
const streamFullMessage = streamUrls.length > 0 ? `${streamMsg}\n\n[Attached media (use these URLs when posting images, e.g. Facebook photo post): ${streamUrls.join(', ')}]` : streamMsg;
|
|
1456
|
+
const userMessage = isContinue ? null : (streamFullMessage || null);
|
|
543
1457
|
try {
|
|
544
|
-
await withSessionLock(id, () => runAgentTurnStream({
|
|
1458
|
+
const runResult = await withSessionLock(id, () => runAgentTurnStream({
|
|
545
1459
|
sessionId: id,
|
|
546
|
-
userMessage
|
|
1460
|
+
userMessage,
|
|
547
1461
|
systemPrompt: typeof system_prompt === 'string' ? system_prompt : null,
|
|
548
1462
|
provider: typeof provider === 'string' ? provider : undefined,
|
|
549
1463
|
model: typeof model === 'string' ? model : undefined,
|
|
@@ -553,19 +1467,25 @@ export function createGateway(appMount = null) {
|
|
|
553
1467
|
}, (ev) => {
|
|
554
1468
|
if (ev.type === 'assistant')
|
|
555
1469
|
send('assistant', { delta: ev.delta });
|
|
1470
|
+
else if (ev.type === 'thinking')
|
|
1471
|
+
send('thinking', { delta: ev.delta });
|
|
556
1472
|
else if (ev.type === 'tool_call')
|
|
557
1473
|
send('tool_call', { name: ev.name, result: ev.result });
|
|
558
1474
|
else if (ev.type === 'done')
|
|
559
|
-
send('done', { finalContent: ev.finalContent, turnCount: ev.turnCount });
|
|
1475
|
+
send('done', { finalContent: ev.finalContent, turnCount: ev.turnCount, usage: ev.usage });
|
|
560
1476
|
else if (ev.type === 'error')
|
|
561
1477
|
send('error', { message: ev.message });
|
|
562
1478
|
}));
|
|
1479
|
+
if (runResult.pendingActionId) {
|
|
1480
|
+
send('pending_approval', { pendingActionId: runResult.pendingActionId });
|
|
1481
|
+
}
|
|
563
1482
|
}
|
|
564
1483
|
catch (e) {
|
|
565
1484
|
const err = e;
|
|
566
1485
|
if (err.name === 'AbortError')
|
|
567
1486
|
send('error', { message: 'Client closed request or run timed out' });
|
|
568
1487
|
else {
|
|
1488
|
+
console.error('[gateway] POST /api/agent/sessions/:id/messages/stream error:', err.message, err.stack);
|
|
569
1489
|
log('gateway', 'error', err.message, { stack: err.stack });
|
|
570
1490
|
send('error', { message: err.message });
|
|
571
1491
|
}
|
|
@@ -610,6 +1530,8 @@ export function createGateway(appMount = null) {
|
|
|
610
1530
|
}, (ev) => {
|
|
611
1531
|
if (ev.type === 'assistant')
|
|
612
1532
|
sendWs('assistant', { delta: ev.delta });
|
|
1533
|
+
else if (ev.type === 'thinking')
|
|
1534
|
+
sendWs('thinking', { delta: ev.delta });
|
|
613
1535
|
else if (ev.type === 'tool_call')
|
|
614
1536
|
sendWs('tool_call', { name: ev.name, result: ev.result });
|
|
615
1537
|
else if (ev.type === 'done')
|
|
@@ -666,6 +1588,279 @@ export function createGateway(appMount = null) {
|
|
|
666
1588
|
res.status(500).json({ error: e.message });
|
|
667
1589
|
}
|
|
668
1590
|
});
|
|
1591
|
+
// Scheduled jobs (cron → task type)
|
|
1592
|
+
app.get('/api/schedules', (_req, res) => {
|
|
1593
|
+
try {
|
|
1594
|
+
const jobs = listScheduledJobs(false);
|
|
1595
|
+
res.json({ schedules: jobs });
|
|
1596
|
+
}
|
|
1597
|
+
catch (e) {
|
|
1598
|
+
log('gateway', 'error', e.message);
|
|
1599
|
+
res.status(500).json({ error: e.message });
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1602
|
+
app.post('/api/schedules', (req, res) => {
|
|
1603
|
+
try {
|
|
1604
|
+
const { name, description, cron_expression, task_type, payload, prompt, delivery, provider, model } = req.body || {};
|
|
1605
|
+
if (!cron_expression?.trim()) {
|
|
1606
|
+
res.status(400).json({ error: 'cron_expression required' });
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
const isAgentJob = typeof prompt === 'string' && prompt.trim().length > 0;
|
|
1610
|
+
const effectiveType = isAgentJob ? 'agent_job' : (task_type?.trim() || 'agent_job');
|
|
1611
|
+
const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
1612
|
+
const payloadStr = !isAgentJob && payload != null ? JSON.stringify(payload) : null;
|
|
1613
|
+
const deliveryStr = isAgentJob && delivery != null ? JSON.stringify(delivery) : null;
|
|
1614
|
+
const providerStr = typeof provider === 'string' ? provider.trim() || null : null;
|
|
1615
|
+
const modelStr = typeof model === 'string' ? model.trim() || null : null;
|
|
1616
|
+
insertScheduledJob({
|
|
1617
|
+
id,
|
|
1618
|
+
name: name?.trim() ?? '',
|
|
1619
|
+
description: description?.trim() ?? '',
|
|
1620
|
+
cron_expression: cron_expression.trim(),
|
|
1621
|
+
task_type: effectiveType,
|
|
1622
|
+
payload: payloadStr,
|
|
1623
|
+
prompt: isAgentJob ? prompt.trim() : null,
|
|
1624
|
+
delivery: deliveryStr,
|
|
1625
|
+
provider: providerStr,
|
|
1626
|
+
model: modelStr,
|
|
1627
|
+
enabled: 1,
|
|
1628
|
+
});
|
|
1629
|
+
try {
|
|
1630
|
+
if (isAgentJob) {
|
|
1631
|
+
const deliveryList = Array.isArray(delivery) ? delivery : (delivery ? [delivery] : []);
|
|
1632
|
+
const agentPayload = {
|
|
1633
|
+
jobId: id,
|
|
1634
|
+
name: name?.trim() || id,
|
|
1635
|
+
prompt: prompt.trim(),
|
|
1636
|
+
delivery: deliveryList,
|
|
1637
|
+
};
|
|
1638
|
+
if (providerStr)
|
|
1639
|
+
agentPayload.provider = providerStr;
|
|
1640
|
+
if (modelStr)
|
|
1641
|
+
agentPayload.model = modelStr;
|
|
1642
|
+
scheduleCronById(id, cron_expression.trim(), 'agent_job', agentPayload);
|
|
1643
|
+
}
|
|
1644
|
+
else {
|
|
1645
|
+
scheduleCronById(id, cron_expression.trim(), effectiveType, payload ?? null);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
catch (e) {
|
|
1649
|
+
deleteScheduledJob(id);
|
|
1650
|
+
throw e;
|
|
1651
|
+
}
|
|
1652
|
+
const row = getScheduledJob(id);
|
|
1653
|
+
log('gateway', 'info', 'Schedule created', { id, task_type: effectiveType });
|
|
1654
|
+
res.status(201).json(row);
|
|
1655
|
+
}
|
|
1656
|
+
catch (e) {
|
|
1657
|
+
log('gateway', 'error', e.message);
|
|
1658
|
+
res.status(500).json({ error: e.message });
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
app.patch('/api/schedules/:id', (req, res) => {
|
|
1662
|
+
try {
|
|
1663
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1664
|
+
if (!id) {
|
|
1665
|
+
res.status(400).json({ error: 'Schedule id required' });
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
const row = getScheduledJob(id);
|
|
1669
|
+
if (!row) {
|
|
1670
|
+
res.status(404).json({ error: 'Schedule not found' });
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
const { name, description, cron_expression, task_type, payload, prompt, delivery, provider, model, enabled } = req.body || {};
|
|
1674
|
+
const updates = {};
|
|
1675
|
+
if (typeof name === 'string')
|
|
1676
|
+
updates.name = name;
|
|
1677
|
+
if (typeof description === 'string')
|
|
1678
|
+
updates.description = description;
|
|
1679
|
+
if (typeof cron_expression === 'string')
|
|
1680
|
+
updates.cron_expression = cron_expression;
|
|
1681
|
+
if (typeof task_type === 'string')
|
|
1682
|
+
updates.task_type = task_type;
|
|
1683
|
+
if (payload !== undefined)
|
|
1684
|
+
updates.payload = payload == null ? null : JSON.stringify(payload);
|
|
1685
|
+
if (prompt !== undefined)
|
|
1686
|
+
updates.prompt = prompt == null ? null : String(prompt).trim() || null;
|
|
1687
|
+
if (delivery !== undefined)
|
|
1688
|
+
updates.delivery = delivery == null ? null : JSON.stringify(delivery);
|
|
1689
|
+
if (provider !== undefined)
|
|
1690
|
+
updates.provider = provider == null ? null : (typeof provider === 'string' ? provider.trim() || null : null);
|
|
1691
|
+
if (model !== undefined)
|
|
1692
|
+
updates.model = model == null ? null : (typeof model === 'string' ? model.trim() || null : null);
|
|
1693
|
+
if (enabled !== undefined)
|
|
1694
|
+
updates.enabled = enabled ? 1 : 0;
|
|
1695
|
+
updateScheduledJob(id, updates);
|
|
1696
|
+
unscheduleJob(id);
|
|
1697
|
+
const updated = getScheduledJob(id);
|
|
1698
|
+
if (updated.enabled) {
|
|
1699
|
+
if (updated.prompt?.trim()) {
|
|
1700
|
+
const deliveryList = updated.delivery?.trim() ? JSON.parse(updated.delivery) : [];
|
|
1701
|
+
const agentPayload = {
|
|
1702
|
+
jobId: id,
|
|
1703
|
+
name: updated.name || id,
|
|
1704
|
+
prompt: updated.prompt,
|
|
1705
|
+
delivery: deliveryList,
|
|
1706
|
+
};
|
|
1707
|
+
const prov = updated.provider;
|
|
1708
|
+
const mod = updated.model;
|
|
1709
|
+
if (prov?.trim())
|
|
1710
|
+
agentPayload.provider = prov.trim();
|
|
1711
|
+
if (mod?.trim())
|
|
1712
|
+
agentPayload.model = mod.trim();
|
|
1713
|
+
scheduleCronById(id, updated.cron_expression, 'agent_job', agentPayload);
|
|
1714
|
+
}
|
|
1715
|
+
else {
|
|
1716
|
+
const payloadVal = updated.payload ? JSON.parse(updated.payload) : null;
|
|
1717
|
+
scheduleCronById(id, updated.cron_expression, updated.task_type, payloadVal);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
log('gateway', 'info', 'Schedule updated', { id });
|
|
1721
|
+
res.json(updated);
|
|
1722
|
+
}
|
|
1723
|
+
catch (e) {
|
|
1724
|
+
log('gateway', 'error', e.message);
|
|
1725
|
+
res.status(500).json({ error: e.message });
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
app.delete('/api/schedules/:id', (req, res) => {
|
|
1729
|
+
try {
|
|
1730
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1731
|
+
if (!id) {
|
|
1732
|
+
res.status(400).json({ error: 'Schedule id required' });
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
const row = getScheduledJob(id);
|
|
1736
|
+
if (!row) {
|
|
1737
|
+
res.status(404).json({ error: 'Schedule not found' });
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
unscheduleJob(id);
|
|
1741
|
+
deleteScheduledJob(id);
|
|
1742
|
+
log('gateway', 'info', 'Schedule deleted', { id });
|
|
1743
|
+
res.status(204).send();
|
|
1744
|
+
}
|
|
1745
|
+
catch (e) {
|
|
1746
|
+
log('gateway', 'error', e.message);
|
|
1747
|
+
res.status(500).json({ error: e.message });
|
|
1748
|
+
}
|
|
1749
|
+
});
|
|
1750
|
+
/** Run a scheduled job immediately (test run). */
|
|
1751
|
+
app.post('/api/schedules/:id/run', (req, res) => {
|
|
1752
|
+
try {
|
|
1753
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1754
|
+
if (!id) {
|
|
1755
|
+
res.status(400).json({ error: 'Schedule id required' });
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const row = getScheduledJob(id);
|
|
1759
|
+
if (!row) {
|
|
1760
|
+
res.status(404).json({ error: 'Schedule not found' });
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
let payload;
|
|
1764
|
+
let taskType;
|
|
1765
|
+
if (row.prompt?.trim()) {
|
|
1766
|
+
const deliveryList = row.delivery?.trim() ? JSON.parse(row.delivery) : [];
|
|
1767
|
+
const r = row;
|
|
1768
|
+
const agentPayload = { jobId: id, name: row.name || id, prompt: row.prompt, delivery: deliveryList };
|
|
1769
|
+
if (r.provider?.trim())
|
|
1770
|
+
agentPayload.provider = r.provider.trim();
|
|
1771
|
+
if (r.model?.trim())
|
|
1772
|
+
agentPayload.model = r.model.trim();
|
|
1773
|
+
payload = agentPayload;
|
|
1774
|
+
taskType = 'agent_job';
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
payload = row.payload ? JSON.parse(row.payload) : null;
|
|
1778
|
+
taskType = row.task_type;
|
|
1779
|
+
}
|
|
1780
|
+
const taskId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
1781
|
+
insertTask({ id: taskId, type: taskType, payload, scheduled_at: Date.now() });
|
|
1782
|
+
const enqueueTaskId = app.locals.enqueueTaskId;
|
|
1783
|
+
if (typeof enqueueTaskId === 'function')
|
|
1784
|
+
enqueueTaskId(taskId);
|
|
1785
|
+
log('gateway', 'info', 'Schedule test run enqueued', { scheduleId: id, taskId });
|
|
1786
|
+
res.status(201).json({ id: taskId, type: taskType, status: 'pending' });
|
|
1787
|
+
}
|
|
1788
|
+
catch (e) {
|
|
1789
|
+
log('gateway', 'error', e.message);
|
|
1790
|
+
res.status(500).json({ error: e.message });
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
/** Get run history for a scheduled job. */
|
|
1794
|
+
app.get('/api/schedules/:id/runs', (req, res) => {
|
|
1795
|
+
try {
|
|
1796
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
1797
|
+
if (!id) {
|
|
1798
|
+
res.status(400).json({ error: 'Schedule id required' });
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const row = getScheduledJob(id);
|
|
1802
|
+
if (!row) {
|
|
1803
|
+
res.status(404).json({ error: 'Schedule not found' });
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
const limit = Math.min(parseInt(req.query.limit || '30', 10), 100);
|
|
1807
|
+
const runs = getTasksForJob(id, limit);
|
|
1808
|
+
res.json({ runs });
|
|
1809
|
+
}
|
|
1810
|
+
catch (e) {
|
|
1811
|
+
log('gateway', 'error', e.message);
|
|
1812
|
+
res.status(500).json({ error: e.message });
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
/** Chat attachments: upload image/file, get a URL the agent can use (e.g. for Facebook photo posts). */
|
|
1816
|
+
const uploadsDir = join(projectRoot, 'uploads');
|
|
1817
|
+
const ALLOWED_EXT = /\.(jpg|jpeg|png|gif|webp)$/i;
|
|
1818
|
+
app.post('/api/upload', express.json({ limit: '10mb' }), (req, res) => {
|
|
1819
|
+
try {
|
|
1820
|
+
const { filename, data } = req.body || {};
|
|
1821
|
+
if (typeof data !== 'string') {
|
|
1822
|
+
res.status(400).json({ error: 'Missing or invalid body: { filename, data (base64) }' });
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
const base = typeof filename === 'string' ? filename.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80) : 'file';
|
|
1826
|
+
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : '';
|
|
1827
|
+
const safeExt = ALLOWED_EXT.test(ext) ? ext : '.jpg';
|
|
1828
|
+
const name = `${randomBytes(8).toString('hex')}${safeExt}`;
|
|
1829
|
+
if (!existsSync(uploadsDir))
|
|
1830
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
1831
|
+
const path = join(uploadsDir, name);
|
|
1832
|
+
const buf = Buffer.from(data, 'base64');
|
|
1833
|
+
if (buf.length > 8 * 1024 * 1024) {
|
|
1834
|
+
res.status(400).json({ error: 'File too large (max 8MB)' });
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
writeFileSync(path, buf);
|
|
1838
|
+
const host = req.get('host') || `${config.host}:${config.port}`;
|
|
1839
|
+
const protocol = req.get('x-forwarded-proto') || req.protocol || 'http';
|
|
1840
|
+
const url = `${protocol}://${host}/api/uploads/${name}`;
|
|
1841
|
+
res.json({ url, name });
|
|
1842
|
+
}
|
|
1843
|
+
catch (e) {
|
|
1844
|
+
log('gateway', 'error', e.message);
|
|
1845
|
+
res.status(500).json({ error: e.message });
|
|
1846
|
+
}
|
|
1847
|
+
});
|
|
1848
|
+
app.get('/api/uploads/:name', (req, res) => {
|
|
1849
|
+
const name = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name;
|
|
1850
|
+
if (!name || !/^[a-f0-9]{16}\.(jpg|jpeg|png|gif|webp)$/i.test(name)) {
|
|
1851
|
+
res.status(400).json({ error: 'Invalid upload name' });
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
const path = join(uploadsDir, name);
|
|
1855
|
+
if (!existsSync(path)) {
|
|
1856
|
+
res.status(404).json({ error: 'Not found' });
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
res.sendFile(path, { maxAge: 86400 * 7 }, (err) => {
|
|
1860
|
+
if (err)
|
|
1861
|
+
res.status(500).json({ error: err.message });
|
|
1862
|
+
});
|
|
1863
|
+
});
|
|
669
1864
|
if (existsSync(dashboardDist)) {
|
|
670
1865
|
app.use(express.static(dashboardDist));
|
|
671
1866
|
app.get(/^\/(?!api|health|ws)/, (_req, res) => {
|