@aion0/forge 0.9.0 → 0.9.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/RELEASE_NOTES.md +60 -7
- package/app/api/agents/[id]/test/route.ts +150 -0
- package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
- package/app/api/connectors/tool-test/route.ts +70 -0
- package/app/api/jobs/[id]/cancel/route.ts +50 -0
- package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
- package/app/api/jobs/[id]/run/route.ts +22 -2
- package/app/api/jobs/route.ts +11 -1
- package/app/api/pipelines/[id]/schema/route.ts +53 -0
- package/app/api/pipelines/bulk-delete/route.ts +39 -0
- package/app/api/pipelines/gc/route.ts +27 -0
- package/app/api/schedules/[id]/cancel/route.ts +27 -0
- package/app/api/schedules/[id]/route.ts +173 -0
- package/app/api/schedules/[id]/run/route.ts +45 -0
- package/app/api/schedules/[id]/runs/route.ts +22 -0
- package/app/api/schedules/[id]/stop/route.ts +33 -0
- package/app/api/schedules/route.ts +175 -0
- package/app/api/tasks/bulk-delete/route.ts +47 -0
- package/bin/forge-server.mjs +22 -1
- package/cli/mw.mjs +186 -7657
- package/cli/mw.ts +26 -0
- package/components/ConnectorsPanel.tsx +46 -0
- package/components/Dashboard.tsx +23 -10
- package/components/JobsView.tsx +245 -6
- package/components/PipelineEditor.tsx +38 -1
- package/components/PipelineView.tsx +325 -4
- package/components/ScheduleCreateModal.tsx +1507 -0
- package/components/SchedulesView.tsx +605 -0
- package/components/SettingsModal.tsx +116 -7
- package/docs/Team-Workflow-Integration.md +487 -0
- package/docs/UI-Design-Brief-SidePanel.md +278 -0
- package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
- package/lib/__tests__/foreach-before.test.ts +201 -0
- package/lib/__tests__/foreach-parse.test.ts +114 -0
- package/lib/__tests__/foreach-snapshot.test.ts +112 -0
- package/lib/__tests__/foreach-source.test.ts +105 -0
- package/lib/__tests__/foreach-template.test.ts +112 -0
- package/lib/chat/agent-loop.ts +3 -3
- package/lib/chat-standalone.ts +26 -1
- package/lib/claude-process.ts +8 -5
- package/lib/connectors/sync.ts +8 -2
- package/lib/crypto.ts +1 -1
- package/lib/dirs.ts +22 -7
- package/lib/help-docs/05-pipelines.md +171 -0
- package/lib/help-docs/13-schedules.md +165 -0
- package/lib/help-docs/23-automation-states.md +148 -0
- package/lib/help-docs/CLAUDE.md +6 -6
- package/lib/init.ts +25 -6
- package/lib/jobs/recipes.ts +3 -2
- package/lib/jobs/scheduler.ts +215 -11
- package/lib/jobs/store.ts +79 -3
- package/lib/jobs/types.ts +31 -0
- package/lib/logger.ts +1 -1
- package/lib/notify.ts +13 -6
- package/lib/pipeline-gc.ts +105 -0
- package/lib/pipeline-scheduler.ts +29 -0
- package/lib/pipeline.ts +811 -330
- package/lib/schedules/action-runner.ts +257 -0
- package/lib/schedules/scheduler.ts +422 -0
- package/lib/schedules/state.ts +41 -0
- package/lib/schedules/store.ts +618 -0
- package/lib/schedules/types.ts +117 -0
- package/lib/settings.ts +35 -0
- package/lib/task-manager.ts +56 -13
- package/lib/workflow-marketplace.ts +7 -1
- package/lib/workspace/skill-installer.ts +7 -6
- package/package.json +3 -1
- package/lib/help-docs/19-jobs.md +0 -145
- package/lib/help-docs/20-mantis-bug-fix.md +0 -115
- package/lib/help-docs/22-recipes.md +0 -124
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ScheduleCreateModal — 3-step wizard:
|
|
5
|
+
* 1. Pick body (kind: pipeline | skill, then ref)
|
|
6
|
+
* 2. Fill input (schema-driven for pipeline; project + user_prompt for skill)
|
|
7
|
+
* 3. Trigger (period / cron / once / manual + name)
|
|
8
|
+
*
|
|
9
|
+
* V2 Phase 2: pipeline + skill body. connector_tool + action types
|
|
10
|
+
* (chat / email / telegram) land in phases 3-6.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
type BodyKind = 'pipeline' | 'skill' | 'connector_tool';
|
|
16
|
+
|
|
17
|
+
interface Workflow { name: string; description?: string; }
|
|
18
|
+
interface SkillItem { name: string; displayName?: string; description?: string; installedGlobal?: boolean; installedProjects?: string[]; }
|
|
19
|
+
interface ProjectInfo { path: string; name: string; }
|
|
20
|
+
interface PipelineInputField {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
label?: string;
|
|
24
|
+
required: boolean;
|
|
25
|
+
type: 'string' | 'integer' | 'number' | 'boolean' | 'enum';
|
|
26
|
+
enum?: string[] | null;
|
|
27
|
+
default?: string | number | boolean | null;
|
|
28
|
+
multiline?: boolean;
|
|
29
|
+
}
|
|
30
|
+
interface PipelineSchema { name: string; description: string | null; input: PipelineInputField[]; }
|
|
31
|
+
interface ConnectorToolParam {
|
|
32
|
+
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | string;
|
|
33
|
+
label?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
required?: boolean;
|
|
36
|
+
default?: unknown;
|
|
37
|
+
enum?: unknown[];
|
|
38
|
+
}
|
|
39
|
+
interface ConnectorTool {
|
|
40
|
+
name: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
parameters?: Record<string, ConnectorToolParam>;
|
|
43
|
+
input_schema?: any;
|
|
44
|
+
destructive?: boolean;
|
|
45
|
+
}
|
|
46
|
+
interface ConnectorEntry { id?: string; tools?: Record<string, ConnectorTool>; }
|
|
47
|
+
interface ConnectorPayload {
|
|
48
|
+
plugin_id: string; name: string; description?: string;
|
|
49
|
+
installed: boolean;
|
|
50
|
+
entries?: ConnectorEntry[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Flatten connector → list of {ref, tool} for the picker.
|
|
54
|
+
* ref = "<plugin_id>.<tool_name>" — matches the body_ref shape the
|
|
55
|
+
* scheduler / dispatchTool expect. */
|
|
56
|
+
function flattenConnectorTools(connectors: ConnectorPayload[]): Array<{
|
|
57
|
+
pluginId: string; pluginName: string; toolName: string; ref: string; tool: ConnectorTool;
|
|
58
|
+
}> {
|
|
59
|
+
const out: Array<{ pluginId: string; pluginName: string; toolName: string; ref: string; tool: ConnectorTool }> = [];
|
|
60
|
+
for (const c of connectors) {
|
|
61
|
+
if (!c.installed) continue;
|
|
62
|
+
for (const e of c.entries || []) {
|
|
63
|
+
const tools = e.tools || {};
|
|
64
|
+
for (const [toolName, t] of Object.entries(tools)) {
|
|
65
|
+
out.push({
|
|
66
|
+
pluginId: c.plugin_id,
|
|
67
|
+
pluginName: c.name || c.plugin_id,
|
|
68
|
+
toolName,
|
|
69
|
+
ref: `${c.plugin_id}.${toolName}`,
|
|
70
|
+
tool: { ...t, name: toolName },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Subset of Schedule fields needed to seed the modal in edit mode. */
|
|
79
|
+
export interface EditableSchedule {
|
|
80
|
+
id: string;
|
|
81
|
+
name: string;
|
|
82
|
+
body_kind: BodyKind;
|
|
83
|
+
body_ref: string;
|
|
84
|
+
input: Record<string, unknown>;
|
|
85
|
+
skills?: string[];
|
|
86
|
+
action_kind: 'none' | 'chat' | 'email' | 'telegram';
|
|
87
|
+
action_config: Record<string, unknown>;
|
|
88
|
+
schedule_kind: 'period' | 'once' | 'cron' | 'manual';
|
|
89
|
+
schedule_interval_minutes: number;
|
|
90
|
+
schedule_at: string | null;
|
|
91
|
+
schedule_cron: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Props {
|
|
95
|
+
onClose: () => void;
|
|
96
|
+
onCreated: () => void;
|
|
97
|
+
/** When set, the modal opens in edit mode: body_kind/body_ref locked,
|
|
98
|
+
* submit issues PATCH instead of POST, title/button labels swap. */
|
|
99
|
+
existing?: EditableSchedule | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Cast Schedule.input (Record<string, unknown>) into the modal's string-keyed
|
|
103
|
+
// form. The form coerces everything back to strings anyway; numbers/booleans
|
|
104
|
+
// stringify cleanly.
|
|
105
|
+
function inputToStrings(v: Record<string, unknown> | undefined): Record<string, string> {
|
|
106
|
+
if (!v) return {};
|
|
107
|
+
const out: Record<string, string> = {};
|
|
108
|
+
for (const [k, val] of Object.entries(v)) out[k] = val == null ? '' : String(val);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Convert an ISO-UTC string ("2026-05-23T12:00:00.000Z") into the YYYY-MM-DDTHH:MM
|
|
113
|
+
// local form <input type="datetime-local"> requires. Strips seconds + tz.
|
|
114
|
+
function toLocalDatetimeInput(iso: string): string {
|
|
115
|
+
const d = new Date(iso);
|
|
116
|
+
if (isNaN(d.getTime())) return '';
|
|
117
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
118
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default function ScheduleCreateModal({ onClose, onCreated, existing }: Props) {
|
|
122
|
+
const editing = !!existing;
|
|
123
|
+
|
|
124
|
+
// In edit mode skip past Step 1 (body_kind / body_ref are immutable).
|
|
125
|
+
const [step, setStep] = useState<1 | 2 | 3>(editing ? 2 : 1);
|
|
126
|
+
|
|
127
|
+
// Step 1
|
|
128
|
+
const [bodyKind, setBodyKind] = useState<BodyKind>(existing?.body_kind ?? 'pipeline');
|
|
129
|
+
const [bodyRef, setBodyRef] = useState<string>(existing?.body_ref ?? '');
|
|
130
|
+
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
|
131
|
+
const [skills, setSkills] = useState<SkillItem[]>([]);
|
|
132
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
133
|
+
const [connectors, setConnectors] = useState<ConnectorPayload[]>([]);
|
|
134
|
+
|
|
135
|
+
// Step 2 — for pipeline body
|
|
136
|
+
const [pipelineSchema, setPipelineSchema] = useState<PipelineSchema | null>(null);
|
|
137
|
+
// Step 2 — for skill body (free-form)
|
|
138
|
+
const [skillProject, setSkillProject] = useState(
|
|
139
|
+
existing?.body_kind === 'skill' ? String(existing.input?.project ?? '') : ''
|
|
140
|
+
);
|
|
141
|
+
const [skillUserPrompt, setSkillUserPrompt] = useState(
|
|
142
|
+
existing?.body_kind === 'skill' ? String(existing.input?.user_prompt ?? '') : ''
|
|
143
|
+
);
|
|
144
|
+
// Step 2 — for connector_tool body
|
|
145
|
+
const [toolInputJson, setToolInputJson] = useState(
|
|
146
|
+
existing?.body_kind === 'connector_tool' ? JSON.stringify(existing.input ?? {}, null, 2) : '{}'
|
|
147
|
+
);
|
|
148
|
+
const [toolInputJsonErr, setToolInputJsonErr] = useState('');
|
|
149
|
+
|
|
150
|
+
const [input, setInput] = useState<Record<string, string>>(
|
|
151
|
+
existing?.body_kind === 'pipeline' ? inputToStrings(existing.input) : {}
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Extra skills attached to the dispatched body (pipeline tasks / skill task).
|
|
155
|
+
// Parallel to Job.skills — forwarded as --append-system-prompt; auto-installed
|
|
156
|
+
// into the target project on dispatch. Not meaningful for connector_tool.
|
|
157
|
+
const [extraSkills, setExtraSkills] = useState<string[]>(Array.isArray(existing?.skills) ? existing!.skills! : []);
|
|
158
|
+
const [extraSkillsPickerOpen, setExtraSkillsPickerOpen] = useState(false);
|
|
159
|
+
// /api/skills returns installedGlobal/installedProjects; we list ALL for the
|
|
160
|
+
// picker (auto-install handles missing ones) but body_kind=skill picker uses
|
|
161
|
+
// a filtered list (separate state `skills`).
|
|
162
|
+
const [allSkills, setAllSkills] = useState<SkillItem[]>([]);
|
|
163
|
+
|
|
164
|
+
// Step 3
|
|
165
|
+
const [name, setName] = useState(existing?.name ?? '');
|
|
166
|
+
const [trigger, setTrigger] = useState<'period' | 'cron' | 'once' | 'manual'>(existing?.schedule_kind ?? 'period');
|
|
167
|
+
const [interval, setInterval] = useState(existing?.schedule_interval_minutes ?? 10);
|
|
168
|
+
const [cron, setCron] = useState(existing?.schedule_cron ?? '*/15 * * * *');
|
|
169
|
+
const [scheduleAt, setScheduleAt] = useState(
|
|
170
|
+
// <input type=datetime-local> expects YYYY-MM-DDTHH:MM (local), not ISO Z.
|
|
171
|
+
existing?.schedule_at ? toLocalDatetimeInput(existing.schedule_at) : ''
|
|
172
|
+
);
|
|
173
|
+
const [actionKind, setActionKind] = useState<'none' | 'chat' | 'email' | 'telegram'>(existing?.action_kind ?? 'none');
|
|
174
|
+
const [chatSessionId, setChatSessionId] = useState(
|
|
175
|
+
existing?.action_kind === 'chat' ? String(existing.action_config?.session_id ?? '') : ''
|
|
176
|
+
);
|
|
177
|
+
const [chatPrefix, setChatPrefix] = useState(
|
|
178
|
+
existing?.action_kind === 'chat' ? String(existing.action_config?.prefix ?? '') : ''
|
|
179
|
+
);
|
|
180
|
+
const [chatSessions, setChatSessions] = useState<Array<{ id: string; title: string }>>([]);
|
|
181
|
+
const [emailTo, setEmailTo] = useState(
|
|
182
|
+
existing?.action_kind === 'email'
|
|
183
|
+
? (Array.isArray(existing.action_config?.to)
|
|
184
|
+
? (existing.action_config.to as string[]).join(', ')
|
|
185
|
+
: String(existing.action_config?.to ?? ''))
|
|
186
|
+
: ''
|
|
187
|
+
);
|
|
188
|
+
const [emailSubjectTpl, setEmailSubjectTpl] = useState(
|
|
189
|
+
existing?.action_kind === 'email'
|
|
190
|
+
? String(existing.action_config?.subject_template ?? 'Forge schedule — {date}')
|
|
191
|
+
: 'Forge schedule — {date}'
|
|
192
|
+
);
|
|
193
|
+
const [emailBodyTpl, setEmailBodyTpl] = useState(
|
|
194
|
+
existing?.action_kind === 'email'
|
|
195
|
+
? String(existing.action_config?.body_template ?? '{body_output}')
|
|
196
|
+
: '{body_output}'
|
|
197
|
+
);
|
|
198
|
+
const [telegramChatId, setTelegramChatId] = useState(
|
|
199
|
+
existing?.action_kind === 'telegram' ? String(existing.action_config?.chat_id ?? '') : ''
|
|
200
|
+
);
|
|
201
|
+
const [telegramPrefix, setTelegramPrefix] = useState(
|
|
202
|
+
existing?.action_kind === 'telegram' ? String(existing.action_config?.prefix ?? '') : ''
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const [submitting, setSubmitting] = useState(false);
|
|
206
|
+
const [err, setErr] = useState('');
|
|
207
|
+
|
|
208
|
+
// Load source lists on mount
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
(async () => {
|
|
211
|
+
try {
|
|
212
|
+
const [wfR, skR, cnR, csR] = await Promise.all([
|
|
213
|
+
fetch('/api/pipelines?type=workflows').then((r) => r.json()),
|
|
214
|
+
fetch('/api/skills').then((r) => r.json()),
|
|
215
|
+
fetch('/api/connectors').then((r) => r.json()).catch(() => ({ connectors: [] })),
|
|
216
|
+
fetch('/api/chat-proxy/sessions').then((r) => r.json()).catch(() => ({ sessions: [] })),
|
|
217
|
+
]);
|
|
218
|
+
if (Array.isArray(csR?.sessions)) setChatSessions(csR.sessions);
|
|
219
|
+
if (Array.isArray(wfR)) setWorkflows(wfR);
|
|
220
|
+
else if (wfR?.workflows) setWorkflows(wfR.workflows);
|
|
221
|
+
if (Array.isArray(skR?.skills)) {
|
|
222
|
+
// Body picker only shows installed skills (forces user to install first).
|
|
223
|
+
setSkills(skR.skills.filter((s: SkillItem) => s.installedGlobal || (s.installedProjects?.length ?? 0) > 0));
|
|
224
|
+
// Extra-skills picker lists everything — Forge auto-installs missing
|
|
225
|
+
// ones into the target project at dispatch time.
|
|
226
|
+
setAllSkills(skR.skills);
|
|
227
|
+
}
|
|
228
|
+
if (skR?.projects) setProjects(skR.projects);
|
|
229
|
+
const conns: ConnectorPayload[] = Array.isArray(cnR) ? cnR : (cnR?.connectors || []);
|
|
230
|
+
setConnectors(conns.filter((c) => c.installed));
|
|
231
|
+
} catch (e) {
|
|
232
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
233
|
+
}
|
|
234
|
+
})();
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
// Load pipeline schema when pipeline body picked
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (bodyKind !== 'pipeline' || !bodyRef) { setPipelineSchema(null); return; }
|
|
240
|
+
(async () => {
|
|
241
|
+
try {
|
|
242
|
+
const r = await fetch(`/api/pipelines/${encodeURIComponent(bodyRef)}/schema`);
|
|
243
|
+
if (!r.ok) {
|
|
244
|
+
const j = await r.json().catch(() => ({}));
|
|
245
|
+
setErr(j.error || `Schema fetch failed (${r.status})`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const j = (await r.json()) as PipelineSchema;
|
|
249
|
+
setPipelineSchema(j);
|
|
250
|
+
const initial: Record<string, string> = {};
|
|
251
|
+
for (const f of j.input) {
|
|
252
|
+
// Prefer existing user-typed value, then the schema-declared
|
|
253
|
+
// default (so v2 multiline defaults like user_prompt / mr_body_template
|
|
254
|
+
// pre-fill the textarea instead of being empty placeholder hints).
|
|
255
|
+
initial[f.name] = input[f.name] ?? (f.default != null ? String(f.default) : '');
|
|
256
|
+
}
|
|
257
|
+
setInput(initial);
|
|
258
|
+
if (!name) {
|
|
259
|
+
const firstVal = Object.values(initial).find((v) => v.length > 0);
|
|
260
|
+
setName(`${bodyRef}${firstVal ? ' · ' + firstVal : ''}`);
|
|
261
|
+
}
|
|
262
|
+
} catch (e) {
|
|
263
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
264
|
+
}
|
|
265
|
+
})();
|
|
266
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
267
|
+
}, [bodyKind, bodyRef]);
|
|
268
|
+
|
|
269
|
+
// Auto-suggest name for skill body
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (bodyKind === 'skill' && bodyRef && !name) {
|
|
272
|
+
setName(`${bodyRef}`);
|
|
273
|
+
}
|
|
274
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
275
|
+
}, [bodyKind, bodyRef]);
|
|
276
|
+
|
|
277
|
+
// Reset body_ref when body_kind switches
|
|
278
|
+
function onBodyKindChange(k: BodyKind) {
|
|
279
|
+
setBodyKind(k);
|
|
280
|
+
setBodyRef('');
|
|
281
|
+
setInput({});
|
|
282
|
+
setPipelineSchema(null);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function canProceed(): boolean {
|
|
286
|
+
if (step === 1) return !!bodyRef;
|
|
287
|
+
if (step === 2) {
|
|
288
|
+
if (bodyKind === 'pipeline') {
|
|
289
|
+
if (!pipelineSchema) return false;
|
|
290
|
+
for (const f of pipelineSchema.input) {
|
|
291
|
+
if (f.required && !input[f.name]?.trim()) return false;
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
if (bodyKind === 'skill') {
|
|
296
|
+
return !!skillProject && !!skillUserPrompt.trim();
|
|
297
|
+
}
|
|
298
|
+
if (bodyKind === 'connector_tool') {
|
|
299
|
+
return !toolInputJsonErr;
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
if (step === 3) {
|
|
304
|
+
if (!name.trim()) return false;
|
|
305
|
+
if (trigger === 'cron' && !cron.trim()) return false;
|
|
306
|
+
if (trigger === 'once' && !scheduleAt) return false;
|
|
307
|
+
if (actionKind === 'chat' && !chatSessionId) return false;
|
|
308
|
+
if (actionKind === 'email' && !emailTo.trim()) return false;
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function submit() {
|
|
315
|
+
setErr('');
|
|
316
|
+
setSubmitting(true);
|
|
317
|
+
try {
|
|
318
|
+
let body_input: Record<string, unknown>;
|
|
319
|
+
if (bodyKind === 'pipeline') {
|
|
320
|
+
body_input = input;
|
|
321
|
+
} else if (bodyKind === 'skill') {
|
|
322
|
+
body_input = { project: skillProject, user_prompt: skillUserPrompt };
|
|
323
|
+
} else { // connector_tool
|
|
324
|
+
try {
|
|
325
|
+
body_input = JSON.parse(toolInputJson || '{}');
|
|
326
|
+
} catch (e) {
|
|
327
|
+
setErr(`Tool input is not valid JSON: ${(e as Error).message}`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const action_config: Record<string, unknown> = {};
|
|
332
|
+
if (actionKind === 'chat') {
|
|
333
|
+
action_config.session_id = chatSessionId;
|
|
334
|
+
if (chatPrefix) action_config.prefix = chatPrefix;
|
|
335
|
+
} else if (actionKind === 'email') {
|
|
336
|
+
const to = emailTo.split(/[,;]\s*/).map((s) => s.trim()).filter(Boolean);
|
|
337
|
+
action_config.to = to.length > 1 ? to : (to[0] || '');
|
|
338
|
+
if (emailSubjectTpl) action_config.subject_template = emailSubjectTpl;
|
|
339
|
+
if (emailBodyTpl) action_config.body_template = emailBodyTpl;
|
|
340
|
+
} else if (actionKind === 'telegram') {
|
|
341
|
+
if (telegramChatId.trim()) action_config.chat_id = telegramChatId.trim();
|
|
342
|
+
if (telegramPrefix) action_config.prefix = telegramPrefix;
|
|
343
|
+
}
|
|
344
|
+
const body: any = {
|
|
345
|
+
name: name.trim(),
|
|
346
|
+
input: body_input,
|
|
347
|
+
// connector_tool ignores skills (no Claude task spawned); send [] so
|
|
348
|
+
// PATCH doesn't preserve a stale value from before kind switch.
|
|
349
|
+
skills: bodyKind === 'connector_tool' ? [] : extraSkills,
|
|
350
|
+
action_kind: actionKind,
|
|
351
|
+
action_config,
|
|
352
|
+
schedule_kind: trigger,
|
|
353
|
+
// Reset stale trigger fields so a kind change wipes the old one.
|
|
354
|
+
schedule_interval_minutes: trigger === 'period' ? interval : null,
|
|
355
|
+
schedule_cron: trigger === 'cron' ? cron.trim() : null,
|
|
356
|
+
schedule_at: trigger === 'once' ? new Date(scheduleAt).toISOString() : null,
|
|
357
|
+
};
|
|
358
|
+
if (!editing) {
|
|
359
|
+
body.body_kind = bodyKind;
|
|
360
|
+
body.body_ref = bodyRef;
|
|
361
|
+
body.enabled = true;
|
|
362
|
+
}
|
|
363
|
+
const url = editing ? `/api/schedules/${encodeURIComponent(existing!.id)}` : '/api/schedules';
|
|
364
|
+
const r = await fetch(url, {
|
|
365
|
+
method: editing ? 'PATCH' : 'POST',
|
|
366
|
+
headers: { 'Content-Type': 'application/json' },
|
|
367
|
+
body: JSON.stringify(body),
|
|
368
|
+
});
|
|
369
|
+
if (!r.ok) {
|
|
370
|
+
const j = await r.json().catch(() => ({}));
|
|
371
|
+
setErr(j.error || `HTTP ${r.status}`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
onCreated();
|
|
375
|
+
} catch (e) {
|
|
376
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
377
|
+
} finally {
|
|
378
|
+
setSubmitting(false);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[12vh]">
|
|
384
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-2xl w-[680px] max-w-[95vw] max-h-[80vh] flex flex-col">
|
|
385
|
+
{/* Header */}
|
|
386
|
+
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center gap-3">
|
|
387
|
+
<h2 className="text-[13px] font-semibold flex-1">{editing ? `✎ Edit Schedule — ${existing?.name}` : '+ Schedule'}</h2>
|
|
388
|
+
<Stepper step={step} />
|
|
389
|
+
<button onClick={onClose} className="text-[14px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] w-6 h-6">✕</button>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{/* Body */}
|
|
393
|
+
<div className="flex-1 overflow-auto p-4">
|
|
394
|
+
{step === 1 && (
|
|
395
|
+
<Step1
|
|
396
|
+
bodyKind={bodyKind} onBodyKindChange={onBodyKindChange}
|
|
397
|
+
bodyRef={bodyRef} onBodyRefChange={setBodyRef}
|
|
398
|
+
workflows={workflows} skills={skills} connectors={connectors}
|
|
399
|
+
projects={projects}
|
|
400
|
+
skillProject={skillProject} onSkillProject={setSkillProject}
|
|
401
|
+
/>
|
|
402
|
+
)}
|
|
403
|
+
{step === 2 && bodyKind === 'pipeline' && pipelineSchema && (
|
|
404
|
+
<Step2Pipeline schema={pipelineSchema} input={input} onChange={setInput} />
|
|
405
|
+
)}
|
|
406
|
+
{step === 2 && bodyKind === 'skill' && (
|
|
407
|
+
<Step2Skill
|
|
408
|
+
project={skillProject}
|
|
409
|
+
userPrompt={skillUserPrompt} onUserPrompt={setSkillUserPrompt}
|
|
410
|
+
skillName={bodyRef}
|
|
411
|
+
/>
|
|
412
|
+
)}
|
|
413
|
+
{step === 2 && bodyKind === 'connector_tool' && (
|
|
414
|
+
<Step2ConnectorTool
|
|
415
|
+
connectors={connectors}
|
|
416
|
+
toolRef={bodyRef}
|
|
417
|
+
json={toolInputJson}
|
|
418
|
+
onJsonChange={(v) => {
|
|
419
|
+
setToolInputJson(v);
|
|
420
|
+
try { JSON.parse(v || '{}'); setToolInputJsonErr(''); }
|
|
421
|
+
catch (e) { setToolInputJsonErr((e as Error).message); }
|
|
422
|
+
}}
|
|
423
|
+
jsonErr={toolInputJsonErr}
|
|
424
|
+
/>
|
|
425
|
+
)}
|
|
426
|
+
{step === 2 && bodyKind !== 'connector_tool' && (
|
|
427
|
+
<ExtraSkillsSection
|
|
428
|
+
skills={extraSkills}
|
|
429
|
+
onChange={setExtraSkills}
|
|
430
|
+
allSkills={allSkills}
|
|
431
|
+
onOpenPicker={() => setExtraSkillsPickerOpen(true)}
|
|
432
|
+
/>
|
|
433
|
+
)}
|
|
434
|
+
{step === 3 && (
|
|
435
|
+
<Step3
|
|
436
|
+
name={name} onName={setName}
|
|
437
|
+
trigger={trigger} onTrigger={setTrigger}
|
|
438
|
+
interval={interval} onInterval={setInterval}
|
|
439
|
+
cron={cron} onCron={setCron}
|
|
440
|
+
scheduleAt={scheduleAt} onScheduleAt={setScheduleAt}
|
|
441
|
+
bodyKind={bodyKind} bodyRef={bodyRef}
|
|
442
|
+
actionKind={actionKind} onActionKind={setActionKind}
|
|
443
|
+
chatSessionId={chatSessionId} onChatSessionId={setChatSessionId}
|
|
444
|
+
chatPrefix={chatPrefix} onChatPrefix={setChatPrefix}
|
|
445
|
+
chatSessions={chatSessions}
|
|
446
|
+
emailTo={emailTo} onEmailTo={setEmailTo}
|
|
447
|
+
emailSubjectTpl={emailSubjectTpl} onEmailSubjectTpl={setEmailSubjectTpl}
|
|
448
|
+
emailBodyTpl={emailBodyTpl} onEmailBodyTpl={setEmailBodyTpl}
|
|
449
|
+
telegramChatId={telegramChatId} onTelegramChatId={setTelegramChatId}
|
|
450
|
+
telegramPrefix={telegramPrefix} onTelegramPrefix={setTelegramPrefix}
|
|
451
|
+
/>
|
|
452
|
+
)}
|
|
453
|
+
{err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2 mt-3">{err}</div>}
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
{/* Footer */}
|
|
457
|
+
<div className="px-4 py-3 border-t border-[var(--border)] flex items-center justify-between">
|
|
458
|
+
<button onClick={onClose} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
|
|
459
|
+
<div className="flex gap-2">
|
|
460
|
+
{step > 1 && !(editing && step === 2) && (
|
|
461
|
+
<button
|
|
462
|
+
onClick={() => setStep((step - 1) as 1 | 2 | 3)}
|
|
463
|
+
disabled={submitting}
|
|
464
|
+
className="text-[11px] px-3 py-1 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)] disabled:opacity-50"
|
|
465
|
+
>← Back</button>
|
|
466
|
+
)}
|
|
467
|
+
{step < 3 ? (
|
|
468
|
+
<button
|
|
469
|
+
onClick={() => setStep((step + 1) as 1 | 2 | 3)}
|
|
470
|
+
disabled={!canProceed()}
|
|
471
|
+
className="text-[11px] px-3 py-1 bg-[var(--text-primary)] text-[var(--bg-primary)] rounded disabled:opacity-50"
|
|
472
|
+
>Next →</button>
|
|
473
|
+
) : (
|
|
474
|
+
<button
|
|
475
|
+
onClick={() => void submit()}
|
|
476
|
+
disabled={!canProceed() || submitting}
|
|
477
|
+
className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
|
|
478
|
+
>{submitting ? (editing ? 'Saving…' : 'Creating…') : (editing ? 'Save' : 'Create')}</button>
|
|
479
|
+
)}
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
{extraSkillsPickerOpen && (
|
|
484
|
+
<MultiSkillPickerModal
|
|
485
|
+
all={allSkills.filter((s) => !s.name.startsWith('_'))}
|
|
486
|
+
selected={extraSkills}
|
|
487
|
+
onClose={() => setExtraSkillsPickerOpen(false)}
|
|
488
|
+
onChange={setExtraSkills}
|
|
489
|
+
/>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function Stepper({ step }: { step: 1 | 2 | 3 }) {
|
|
496
|
+
return (
|
|
497
|
+
<div className="flex items-center gap-1 text-[10px]">
|
|
498
|
+
{[1, 2, 3].map((n) => (
|
|
499
|
+
<div key={n} className="flex items-center">
|
|
500
|
+
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-semibold ${
|
|
501
|
+
n < step ? 'bg-[var(--accent)] text-[var(--bg-primary)]' :
|
|
502
|
+
n === step ? 'bg-[var(--text-primary)] text-[var(--bg-primary)]' :
|
|
503
|
+
'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
|
|
504
|
+
}`}>
|
|
505
|
+
{n < step ? '✓' : n}
|
|
506
|
+
</div>
|
|
507
|
+
{n < 3 && <span className={`w-4 h-px ${n < step ? 'bg-[var(--accent)]' : 'bg-[var(--border)]'} mx-0.5`} />}
|
|
508
|
+
</div>
|
|
509
|
+
))}
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function Step1({
|
|
515
|
+
bodyKind, onBodyKindChange,
|
|
516
|
+
bodyRef, onBodyRefChange,
|
|
517
|
+
workflows, skills, connectors,
|
|
518
|
+
projects, skillProject, onSkillProject,
|
|
519
|
+
}: {
|
|
520
|
+
bodyKind: BodyKind; onBodyKindChange: (k: BodyKind) => void;
|
|
521
|
+
bodyRef: string; onBodyRefChange: (n: string) => void;
|
|
522
|
+
workflows: Workflow[]; skills: SkillItem[]; connectors: ConnectorPayload[];
|
|
523
|
+
projects: ProjectInfo[];
|
|
524
|
+
skillProject: string; onSkillProject: (n: string) => void;
|
|
525
|
+
}) {
|
|
526
|
+
return (
|
|
527
|
+
<div>
|
|
528
|
+
<div className="text-[11px] text-[var(--text-secondary)] mb-2">Step 1 / 3 — Pick what this schedule runs.</div>
|
|
529
|
+
|
|
530
|
+
{/* Body kind tabs */}
|
|
531
|
+
<div className="flex gap-1 mb-3 border-b border-[var(--border)]">
|
|
532
|
+
{(['pipeline', 'skill', 'connector_tool'] as BodyKind[]).map((k) => (
|
|
533
|
+
<button
|
|
534
|
+
key={k}
|
|
535
|
+
onClick={() => onBodyKindChange(k)}
|
|
536
|
+
className={`text-[11px] px-3 py-1.5 border-b-2 ${
|
|
537
|
+
bodyKind === k
|
|
538
|
+
? 'border-[var(--text-primary)] font-semibold'
|
|
539
|
+
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
540
|
+
}`}
|
|
541
|
+
>
|
|
542
|
+
{k === 'pipeline' ? '📋 Pipeline'
|
|
543
|
+
: k === 'skill' ? '🧩 Skill'
|
|
544
|
+
: '🔌 Connector Tool'}
|
|
545
|
+
</button>
|
|
546
|
+
))}
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
{/* Body ref list */}
|
|
550
|
+
{bodyKind === 'pipeline' ? (
|
|
551
|
+
workflows.length === 0 ? (
|
|
552
|
+
<div className="text-[11px] text-[var(--text-secondary)]">Loading pipelines…</div>
|
|
553
|
+
) : (
|
|
554
|
+
<div className="space-y-1.5">
|
|
555
|
+
{workflows.map((w) => (
|
|
556
|
+
<Card key={w.name} title={w.name} desc={w.description}
|
|
557
|
+
checked={bodyRef === w.name} onPick={() => onBodyRefChange(w.name)} />
|
|
558
|
+
))}
|
|
559
|
+
</div>
|
|
560
|
+
)
|
|
561
|
+
) : bodyKind === 'skill' ? (
|
|
562
|
+
// Skill body: project first (required), then skill list scoped to that project (or global).
|
|
563
|
+
<SkillPicker
|
|
564
|
+
skills={skills} projects={projects}
|
|
565
|
+
skillProject={skillProject} onSkillProject={onSkillProject}
|
|
566
|
+
bodyRef={bodyRef} onBodyRefChange={onBodyRefChange}
|
|
567
|
+
/>
|
|
568
|
+
) : (
|
|
569
|
+
// connector_tool — flattened plugin.tool list. Source shape is
|
|
570
|
+
// { connectors: [{plugin_id, name, installed, entries:[{tools:{name:tool}}] }] }
|
|
571
|
+
(() => {
|
|
572
|
+
const all = flattenConnectorTools(connectors);
|
|
573
|
+
if (all.length === 0) {
|
|
574
|
+
return (
|
|
575
|
+
<div className="text-[11px] text-[var(--text-secondary)]">
|
|
576
|
+
No installed connectors with tools. Install one from Settings → Connectors first.
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
// Group by plugin for nice section headers.
|
|
581
|
+
const byPlugin = new Map<string, { name: string; tools: typeof all }>();
|
|
582
|
+
for (const x of all) {
|
|
583
|
+
if (!byPlugin.has(x.pluginId)) byPlugin.set(x.pluginId, { name: x.pluginName, tools: [] });
|
|
584
|
+
byPlugin.get(x.pluginId)!.tools.push(x);
|
|
585
|
+
}
|
|
586
|
+
return (
|
|
587
|
+
<div className="space-y-3">
|
|
588
|
+
{Array.from(byPlugin.entries()).map(([pluginId, group]) => (
|
|
589
|
+
<div key={pluginId}>
|
|
590
|
+
<div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1">{group.name}</div>
|
|
591
|
+
<div className="space-y-1.5">
|
|
592
|
+
{group.tools.map(({ ref, tool }) => (
|
|
593
|
+
<Card key={ref}
|
|
594
|
+
title={ref}
|
|
595
|
+
desc={(tool.description || '') + (tool.destructive ? ' ⚠ destructive' : '')}
|
|
596
|
+
checked={bodyRef === ref}
|
|
597
|
+
onPick={() => onBodyRefChange(ref)} />
|
|
598
|
+
))}
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
))}
|
|
602
|
+
</div>
|
|
603
|
+
);
|
|
604
|
+
})()
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function SkillPicker({ skills, projects, skillProject, onSkillProject, bodyRef, onBodyRefChange }: {
|
|
611
|
+
skills: SkillItem[]; projects: ProjectInfo[];
|
|
612
|
+
skillProject: string; onSkillProject: (n: string) => void;
|
|
613
|
+
bodyRef: string; onBodyRefChange: (n: string) => void;
|
|
614
|
+
}) {
|
|
615
|
+
// Filter skills to those installed globally OR in the chosen project.
|
|
616
|
+
// If no project selected yet, show nothing in the list (prompt user to pick project first).
|
|
617
|
+
const visibleSkills = skillProject
|
|
618
|
+
? skills.filter((s) => s.installedGlobal || (s.installedProjects || []).includes(skillProject)
|
|
619
|
+
|| (s.installedProjects || []).some((p) => p.endsWith('/' + skillProject) || p === skillProject))
|
|
620
|
+
: [];
|
|
621
|
+
return (
|
|
622
|
+
<div>
|
|
623
|
+
<label className="block mb-3">
|
|
624
|
+
<div className="flex items-baseline gap-1 text-[11px] mb-0.5">
|
|
625
|
+
<span className="font-mono">project</span>
|
|
626
|
+
<span className="text-[var(--red)]">*</span>
|
|
627
|
+
</div>
|
|
628
|
+
<select
|
|
629
|
+
value={skillProject}
|
|
630
|
+
onChange={(e) => { onSkillProject(e.target.value); onBodyRefChange(''); }}
|
|
631
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
632
|
+
>
|
|
633
|
+
<option value="">— pick a project —</option>
|
|
634
|
+
{projects.map((p) => (
|
|
635
|
+
<option key={p.name} value={p.name}>{p.name}</option>
|
|
636
|
+
))}
|
|
637
|
+
</select>
|
|
638
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
|
639
|
+
Working directory for the Claude task. Skills installed in this project + global skills will appear below.
|
|
640
|
+
</div>
|
|
641
|
+
</label>
|
|
642
|
+
|
|
643
|
+
<div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1">
|
|
644
|
+
Skill
|
|
645
|
+
{skillProject && visibleSkills.length > 0 && (
|
|
646
|
+
<span className="ml-2 normal-case text-[var(--text-secondary)] font-normal">
|
|
647
|
+
({visibleSkills.length} available in <code className="font-mono">{skillProject}</code>)
|
|
648
|
+
</span>
|
|
649
|
+
)}
|
|
650
|
+
</div>
|
|
651
|
+
{!skillProject ? (
|
|
652
|
+
<div className="text-[11px] text-[var(--text-secondary)] italic">Pick a project first.</div>
|
|
653
|
+
) : visibleSkills.length === 0 ? (
|
|
654
|
+
<div className="text-[11px] text-[var(--text-secondary)]">
|
|
655
|
+
No skills installed globally or in <code className="font-mono">{skillProject}</code>. Install one from Settings → Skills.
|
|
656
|
+
</div>
|
|
657
|
+
) : (
|
|
658
|
+
<div className="space-y-1.5">
|
|
659
|
+
{visibleSkills.map((s) => (
|
|
660
|
+
<Card key={s.name} title={s.name} desc={s.description}
|
|
661
|
+
checked={bodyRef === s.name} onPick={() => onBodyRefChange(s.name)} />
|
|
662
|
+
))}
|
|
663
|
+
</div>
|
|
664
|
+
)}
|
|
665
|
+
</div>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function Card({ title, desc, checked, onPick }: {
|
|
670
|
+
title: string; desc?: string | null; checked: boolean; onPick: () => void;
|
|
671
|
+
}) {
|
|
672
|
+
return (
|
|
673
|
+
<label
|
|
674
|
+
className={`block cursor-pointer rounded border p-2.5 ${
|
|
675
|
+
checked
|
|
676
|
+
? 'border-[var(--text-primary)] bg-[var(--bg-secondary)]'
|
|
677
|
+
: 'border-[var(--border)] hover:bg-[var(--bg-secondary)]/50'
|
|
678
|
+
}`}
|
|
679
|
+
>
|
|
680
|
+
<div className="flex items-baseline gap-2">
|
|
681
|
+
<input type="radio" name="body-ref" checked={checked} onChange={onPick} />
|
|
682
|
+
<span className="text-[12px] font-mono font-semibold">{title}</span>
|
|
683
|
+
</div>
|
|
684
|
+
{desc && <div className="text-[10px] text-[var(--text-secondary)] mt-1 ml-5">{desc}</div>}
|
|
685
|
+
</label>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function Step2Pipeline({ schema, input, onChange }: {
|
|
690
|
+
schema: PipelineSchema; input: Record<string, string>; onChange: (v: Record<string, string>) => void;
|
|
691
|
+
}) {
|
|
692
|
+
function update(k: string, v: string) {
|
|
693
|
+
onChange({ ...input, [k]: v });
|
|
694
|
+
}
|
|
695
|
+
return (
|
|
696
|
+
<div>
|
|
697
|
+
<div className="text-[11px] text-[var(--text-secondary)] mb-2">
|
|
698
|
+
Step 2 / 3 — Fill pipeline input. Pipeline: <code className="font-mono">{schema.name}</code>.
|
|
699
|
+
</div>
|
|
700
|
+
{schema.description && (
|
|
701
|
+
<div className="text-[10px] text-[var(--text-secondary)] mb-3 italic">{schema.description}</div>
|
|
702
|
+
)}
|
|
703
|
+
<div className="space-y-3">
|
|
704
|
+
{schema.input.map((f) => (
|
|
705
|
+
<PipelineInputControl
|
|
706
|
+
key={f.name} field={f}
|
|
707
|
+
value={input[f.name] ?? (f.default != null ? String(f.default) : '')}
|
|
708
|
+
onChange={(v) => update(f.name, v)}
|
|
709
|
+
/>
|
|
710
|
+
))}
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function PipelineInputControl({ field, value, onChange }: {
|
|
717
|
+
field: PipelineInputField; value: string; onChange: (v: string) => void;
|
|
718
|
+
}) {
|
|
719
|
+
const labelEl = (
|
|
720
|
+
<div className="flex items-baseline gap-1 text-[11px] mb-0.5">
|
|
721
|
+
<span className="font-mono">{field.name}</span>
|
|
722
|
+
{field.required && <span className="text-[var(--red)]">*</span>}
|
|
723
|
+
{field.label && <span className="text-[10px] text-[var(--text-secondary)]">— {field.label}</span>}
|
|
724
|
+
</div>
|
|
725
|
+
);
|
|
726
|
+
const descEl = field.description ? (
|
|
727
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5 whitespace-pre-wrap">{field.description}</div>
|
|
728
|
+
) : null;
|
|
729
|
+
|
|
730
|
+
// enum → select
|
|
731
|
+
if (field.type === 'enum' && Array.isArray(field.enum) && field.enum.length > 0) {
|
|
732
|
+
return (
|
|
733
|
+
<label className="block">
|
|
734
|
+
{labelEl}
|
|
735
|
+
<select
|
|
736
|
+
value={value}
|
|
737
|
+
onChange={(e) => onChange(e.target.value)}
|
|
738
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
739
|
+
>
|
|
740
|
+
<option value="">— pick —</option>
|
|
741
|
+
{field.enum.map((opt) => (
|
|
742
|
+
<option key={opt} value={opt}>{opt}</option>
|
|
743
|
+
))}
|
|
744
|
+
</select>
|
|
745
|
+
{descEl}
|
|
746
|
+
</label>
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// boolean → checkbox; value is "true"/"false" string (pipeline templates do string substitution)
|
|
751
|
+
if (field.type === 'boolean') {
|
|
752
|
+
const checked = value === 'true' || value === '1';
|
|
753
|
+
return (
|
|
754
|
+
<label className="block">
|
|
755
|
+
{labelEl}
|
|
756
|
+
<label className="flex items-center gap-2 text-[11px]">
|
|
757
|
+
<input
|
|
758
|
+
type="checkbox"
|
|
759
|
+
checked={checked}
|
|
760
|
+
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
|
|
761
|
+
/>
|
|
762
|
+
<span className="text-[11px] text-[var(--text-secondary)]">{checked ? 'true' : 'false'}</span>
|
|
763
|
+
</label>
|
|
764
|
+
{descEl}
|
|
765
|
+
</label>
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// number / integer
|
|
770
|
+
if (field.type === 'number' || field.type === 'integer') {
|
|
771
|
+
return (
|
|
772
|
+
<label className="block">
|
|
773
|
+
{labelEl}
|
|
774
|
+
<input
|
|
775
|
+
type="number"
|
|
776
|
+
step={field.type === 'integer' ? 1 : 'any'}
|
|
777
|
+
value={value}
|
|
778
|
+
onChange={(e) => onChange(e.target.value)}
|
|
779
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
780
|
+
/>
|
|
781
|
+
{descEl}
|
|
782
|
+
</label>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// string (default) — multiline if field declares it OR fallback to heuristic
|
|
787
|
+
const isMultiLine = field.multiline === true ||
|
|
788
|
+
(field.multiline === undefined && (
|
|
789
|
+
/multi-line|multiline|focus|prompt|description|text|body|template/i.test(field.description || '') ||
|
|
790
|
+
/_text$|_focus$|_prompt$|_body$|_template$/.test(field.name)
|
|
791
|
+
));
|
|
792
|
+
return (
|
|
793
|
+
<label className="block">
|
|
794
|
+
{labelEl}
|
|
795
|
+
{isMultiLine ? (
|
|
796
|
+
<textarea
|
|
797
|
+
rows={3}
|
|
798
|
+
value={value}
|
|
799
|
+
onChange={(e) => onChange(e.target.value)}
|
|
800
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
801
|
+
/>
|
|
802
|
+
) : (
|
|
803
|
+
<input
|
|
804
|
+
type="text"
|
|
805
|
+
value={value}
|
|
806
|
+
onChange={(e) => onChange(e.target.value)}
|
|
807
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
808
|
+
/>
|
|
809
|
+
)}
|
|
810
|
+
{descEl}
|
|
811
|
+
</label>
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function Step2Skill({ project, userPrompt, onUserPrompt, skillName }: {
|
|
816
|
+
project: string;
|
|
817
|
+
userPrompt: string; onUserPrompt: (v: string) => void; skillName: string;
|
|
818
|
+
}) {
|
|
819
|
+
return (
|
|
820
|
+
<div>
|
|
821
|
+
<div className="text-[11px] text-[var(--text-secondary)] mb-3">
|
|
822
|
+
Step 2 / 3 — Skill <code className="font-mono">/{skillName}</code> in
|
|
823
|
+
project <code className="font-mono">{project}</code>.
|
|
824
|
+
</div>
|
|
825
|
+
|
|
826
|
+
<label className="block">
|
|
827
|
+
<div className="flex items-baseline gap-1 text-[11px] mb-0.5">
|
|
828
|
+
<span className="font-mono">user_prompt</span>
|
|
829
|
+
<span className="text-[var(--red)]">*</span>
|
|
830
|
+
</div>
|
|
831
|
+
<textarea
|
|
832
|
+
rows={6}
|
|
833
|
+
value={userPrompt}
|
|
834
|
+
onChange={(e) => onUserPrompt(e.target.value)}
|
|
835
|
+
placeholder="e.g. Fetch all my open Mantis bugs, summarize by category"
|
|
836
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
837
|
+
/>
|
|
838
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
|
839
|
+
The user message sent to Claude. The skill's SKILL.md prompts Claude on how to handle it.
|
|
840
|
+
</div>
|
|
841
|
+
</label>
|
|
842
|
+
</div>
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function Step2ConnectorTool({ connectors, toolRef, json, onJsonChange, jsonErr }: {
|
|
847
|
+
connectors: ConnectorPayload[]; toolRef: string;
|
|
848
|
+
json: string; onJsonChange: (v: string) => void; jsonErr: string;
|
|
849
|
+
}) {
|
|
850
|
+
const found = flattenConnectorTools(connectors).find((x) => x.ref === toolRef);
|
|
851
|
+
const tool = found?.tool;
|
|
852
|
+
const params = tool?.parameters || {};
|
|
853
|
+
const paramNames = Object.keys(params);
|
|
854
|
+
|
|
855
|
+
// Parse json → object for the form to read. If json is invalid the
|
|
856
|
+
// form shows current "field" values from whatever we last parsed
|
|
857
|
+
// successfully (the editor is the source of truth either way).
|
|
858
|
+
const parsed: Record<string, unknown> = (() => {
|
|
859
|
+
try { const v = JSON.parse(json || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; }
|
|
860
|
+
catch { return {}; }
|
|
861
|
+
})();
|
|
862
|
+
|
|
863
|
+
// Toggle: form vs raw json. Default to form if the tool declares
|
|
864
|
+
// parameters; fall back to raw JSON for tools without a schema.
|
|
865
|
+
const [showRaw, setShowRaw] = useState(paramNames.length === 0);
|
|
866
|
+
|
|
867
|
+
// Test result panel — populated by the Test button below the form.
|
|
868
|
+
const [testing, setTesting] = useState(false);
|
|
869
|
+
const [testResult, setTestResult] = useState<null | {
|
|
870
|
+
ok: boolean;
|
|
871
|
+
is_error: boolean;
|
|
872
|
+
content: string;
|
|
873
|
+
duration_ms: number;
|
|
874
|
+
error?: string;
|
|
875
|
+
}>(null);
|
|
876
|
+
const pluginId = toolRef.split('.')[0] || '';
|
|
877
|
+
const toolName = toolRef.split('.').slice(1).join('.');
|
|
878
|
+
async function runTest() {
|
|
879
|
+
setTesting(true);
|
|
880
|
+
setTestResult(null);
|
|
881
|
+
try {
|
|
882
|
+
const r = await fetch('/api/connectors/tool-test', {
|
|
883
|
+
method: 'POST',
|
|
884
|
+
headers: { 'Content-Type': 'application/json' },
|
|
885
|
+
body: JSON.stringify({ plugin_id: pluginId, tool: toolName, input: parsed }),
|
|
886
|
+
});
|
|
887
|
+
const j = await r.json();
|
|
888
|
+
setTestResult(j);
|
|
889
|
+
} catch (e) {
|
|
890
|
+
setTestResult({ ok: false, is_error: true, content: '', duration_ms: 0, error: (e as Error).message });
|
|
891
|
+
} finally { setTesting(false); }
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function updateField(name: string, value: unknown) {
|
|
895
|
+
const next = { ...parsed };
|
|
896
|
+
// Drop empty optional fields so they don't pollute the payload.
|
|
897
|
+
if (value === '' || value === null || value === undefined) delete next[name];
|
|
898
|
+
else next[name] = value;
|
|
899
|
+
onJsonChange(JSON.stringify(next, null, 2));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return (
|
|
903
|
+
<div>
|
|
904
|
+
<div className="text-[11px] text-[var(--text-secondary)] mb-3">
|
|
905
|
+
Step 2 / 3 — Tool: <code className="font-mono">{toolRef}</code>. Dispatched
|
|
906
|
+
directly (no LLM); the response becomes the body output.
|
|
907
|
+
</div>
|
|
908
|
+
|
|
909
|
+
{tool?.description && (
|
|
910
|
+
<div className="text-[10px] text-[var(--text-secondary)] italic mb-3 whitespace-pre-wrap">{tool.description}</div>
|
|
911
|
+
)}
|
|
912
|
+
{tool?.destructive && (
|
|
913
|
+
<div className="text-[10px] text-[var(--yellow)] bg-[var(--yellow)]/10 rounded p-2 mb-3">
|
|
914
|
+
⚠ This tool is marked <code className="font-mono">destructive</code>. Schedule will invoke it
|
|
915
|
+
every trigger — make sure that's what you want.
|
|
916
|
+
</div>
|
|
917
|
+
)}
|
|
918
|
+
|
|
919
|
+
{/* mode toggle */}
|
|
920
|
+
{paramNames.length > 0 && (
|
|
921
|
+
<div className="flex gap-2 mb-3 text-[10px]">
|
|
922
|
+
<button
|
|
923
|
+
onClick={() => setShowRaw(false)}
|
|
924
|
+
className={`px-2 py-0.5 rounded border ${!showRaw ? 'border-[var(--text-primary)] bg-[var(--bg-secondary)]' : 'border-[var(--border)] text-[var(--text-secondary)]'}`}
|
|
925
|
+
>Form</button>
|
|
926
|
+
<button
|
|
927
|
+
onClick={() => setShowRaw(true)}
|
|
928
|
+
className={`px-2 py-0.5 rounded border ${showRaw ? 'border-[var(--text-primary)] bg-[var(--bg-secondary)]' : 'border-[var(--border)] text-[var(--text-secondary)]'}`}
|
|
929
|
+
>Raw JSON</button>
|
|
930
|
+
</div>
|
|
931
|
+
)}
|
|
932
|
+
|
|
933
|
+
{/* form mode */}
|
|
934
|
+
{!showRaw && paramNames.length > 0 && (
|
|
935
|
+
<div className="space-y-3">
|
|
936
|
+
{paramNames.map((name) => {
|
|
937
|
+
const p = params[name];
|
|
938
|
+
const cur = parsed[name];
|
|
939
|
+
return (
|
|
940
|
+
<ConnectorToolField
|
|
941
|
+
key={name}
|
|
942
|
+
name={name}
|
|
943
|
+
param={p}
|
|
944
|
+
value={cur}
|
|
945
|
+
onChange={(v) => updateField(name, v)}
|
|
946
|
+
/>
|
|
947
|
+
);
|
|
948
|
+
})}
|
|
949
|
+
{jsonErr && (
|
|
950
|
+
<div className="text-[10px] text-[var(--red)]">underlying JSON is invalid: {jsonErr}</div>
|
|
951
|
+
)}
|
|
952
|
+
</div>
|
|
953
|
+
)}
|
|
954
|
+
|
|
955
|
+
{/* raw json mode (or fallback when tool has no parameters) */}
|
|
956
|
+
{(showRaw || paramNames.length === 0) && (
|
|
957
|
+
<label className="block">
|
|
958
|
+
<div className="flex items-baseline gap-1 text-[11px] mb-0.5">
|
|
959
|
+
<span className="font-mono">input (JSON)</span>
|
|
960
|
+
</div>
|
|
961
|
+
<textarea
|
|
962
|
+
rows={8}
|
|
963
|
+
value={json}
|
|
964
|
+
onChange={(e) => onJsonChange(e.target.value)}
|
|
965
|
+
placeholder='{ "bug_id": 1213844 }'
|
|
966
|
+
spellCheck={false}
|
|
967
|
+
className={`w-full text-[11px] font-mono px-2 py-1 border rounded bg-[var(--bg-secondary)] ${
|
|
968
|
+
jsonErr ? 'border-[var(--red)]' : 'border-[var(--border)]'
|
|
969
|
+
}`}
|
|
970
|
+
/>
|
|
971
|
+
{jsonErr && (
|
|
972
|
+
<div className="text-[10px] text-[var(--red)] mt-0.5">JSON parse error: {jsonErr}</div>
|
|
973
|
+
)}
|
|
974
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
|
975
|
+
Passed verbatim as the tool's input. Leave as <code className="font-mono">{`{}`}</code> if the tool takes no args.
|
|
976
|
+
</div>
|
|
977
|
+
</label>
|
|
978
|
+
)}
|
|
979
|
+
|
|
980
|
+
{/* Test button + result — actually dispatch the tool with the current
|
|
981
|
+
input so the user can validate before saving. */}
|
|
982
|
+
<div className="mt-3 pt-3 border-t border-[var(--border)]">
|
|
983
|
+
<div className="flex items-center gap-2 mb-2">
|
|
984
|
+
<button
|
|
985
|
+
onClick={() => void runTest()}
|
|
986
|
+
disabled={testing || !!jsonErr}
|
|
987
|
+
className="text-[11px] px-3 py-1 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)] disabled:opacity-50"
|
|
988
|
+
>{testing ? 'Testing…' : '▶ Test'}</button>
|
|
989
|
+
<span className="text-[10px] text-[var(--text-secondary)]">
|
|
990
|
+
Dispatch the tool once with the current input. Read-only check — doesn't save the schedule.
|
|
991
|
+
</span>
|
|
992
|
+
</div>
|
|
993
|
+
{testResult && (
|
|
994
|
+
<div className={`text-[10px] rounded p-2 ${
|
|
995
|
+
testResult.ok ? 'bg-[var(--green)]/10' : 'bg-[var(--red)]/10'
|
|
996
|
+
}`}>
|
|
997
|
+
<div className={testResult.ok ? 'text-[var(--green)]' : 'text-[var(--red)]'}>
|
|
998
|
+
{testResult.ok ? '✓ ok' : '✗ failed'} · {testResult.duration_ms}ms
|
|
999
|
+
{testResult.error && <> · {testResult.error}</>}
|
|
1000
|
+
</div>
|
|
1001
|
+
{testResult.content && (
|
|
1002
|
+
<pre className="mt-1 font-mono whitespace-pre-wrap break-words max-h-60 overflow-auto text-[var(--text-primary)]">
|
|
1003
|
+
{testResult.content}
|
|
1004
|
+
</pre>
|
|
1005
|
+
)}
|
|
1006
|
+
</div>
|
|
1007
|
+
)}
|
|
1008
|
+
</div>
|
|
1009
|
+
</div>
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function ConnectorToolField({ name, param, value, onChange }: {
|
|
1014
|
+
name: string; param: ConnectorToolParam; value: unknown;
|
|
1015
|
+
onChange: (v: unknown) => void;
|
|
1016
|
+
}) {
|
|
1017
|
+
const label = param.label || name;
|
|
1018
|
+
const type = (param.type || 'string').toLowerCase();
|
|
1019
|
+
const required = !!param.required;
|
|
1020
|
+
const hint = param.description;
|
|
1021
|
+
|
|
1022
|
+
const labelEl = (
|
|
1023
|
+
<div className="flex items-baseline gap-1 text-[11px] mb-0.5">
|
|
1024
|
+
<span className="font-mono">{name}</span>
|
|
1025
|
+
{required && <span className="text-[var(--red)]">*</span>}
|
|
1026
|
+
{param.label && <span className="text-[10px] text-[var(--text-secondary)]">— {label}</span>}
|
|
1027
|
+
</div>
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
// enum → select
|
|
1031
|
+
if (Array.isArray(param.enum) && param.enum.length > 0) {
|
|
1032
|
+
const cur = value == null ? '' : String(value);
|
|
1033
|
+
return (
|
|
1034
|
+
<label className="block">
|
|
1035
|
+
{labelEl}
|
|
1036
|
+
<select
|
|
1037
|
+
value={cur}
|
|
1038
|
+
onChange={(e) => onChange(e.target.value || undefined)}
|
|
1039
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1040
|
+
>
|
|
1041
|
+
<option value="">— pick —</option>
|
|
1042
|
+
{param.enum.map((opt) => (
|
|
1043
|
+
<option key={String(opt)} value={String(opt)}>{String(opt)}</option>
|
|
1044
|
+
))}
|
|
1045
|
+
</select>
|
|
1046
|
+
{hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
|
|
1047
|
+
</label>
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// boolean → checkbox
|
|
1052
|
+
if (type === 'boolean') {
|
|
1053
|
+
return (
|
|
1054
|
+
<label className="block">
|
|
1055
|
+
{labelEl}
|
|
1056
|
+
<label className="flex items-center gap-2 text-[11px]">
|
|
1057
|
+
<input
|
|
1058
|
+
type="checkbox"
|
|
1059
|
+
checked={!!value}
|
|
1060
|
+
onChange={(e) => onChange(e.target.checked || undefined)}
|
|
1061
|
+
/>
|
|
1062
|
+
<span className="text-[11px] text-[var(--text-secondary)]">{value ? 'true' : 'false'}</span>
|
|
1063
|
+
</label>
|
|
1064
|
+
{hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
|
|
1065
|
+
</label>
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// number / integer → number input
|
|
1070
|
+
if (type === 'number' || type === 'integer') {
|
|
1071
|
+
const cur = value == null ? '' : String(value);
|
|
1072
|
+
return (
|
|
1073
|
+
<label className="block">
|
|
1074
|
+
{labelEl}
|
|
1075
|
+
<input
|
|
1076
|
+
type="number"
|
|
1077
|
+
step={type === 'integer' ? 1 : 'any'}
|
|
1078
|
+
value={cur}
|
|
1079
|
+
onChange={(e) => {
|
|
1080
|
+
const v = e.target.value;
|
|
1081
|
+
if (v === '') onChange(undefined);
|
|
1082
|
+
else {
|
|
1083
|
+
const n = type === 'integer' ? parseInt(v, 10) : parseFloat(v);
|
|
1084
|
+
onChange(Number.isFinite(n) ? n : undefined);
|
|
1085
|
+
}
|
|
1086
|
+
}}
|
|
1087
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1088
|
+
/>
|
|
1089
|
+
{hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
|
|
1090
|
+
</label>
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// object / array → JSON textarea
|
|
1095
|
+
if (type === 'object' || type === 'array') {
|
|
1096
|
+
const cur = value == null ? '' : (typeof value === 'string' ? value : JSON.stringify(value, null, 2));
|
|
1097
|
+
return (
|
|
1098
|
+
<label className="block">
|
|
1099
|
+
{labelEl}
|
|
1100
|
+
<textarea
|
|
1101
|
+
rows={3}
|
|
1102
|
+
value={cur}
|
|
1103
|
+
onChange={(e) => {
|
|
1104
|
+
const raw = e.target.value;
|
|
1105
|
+
if (!raw.trim()) { onChange(undefined); return; }
|
|
1106
|
+
try { onChange(JSON.parse(raw)); }
|
|
1107
|
+
catch { onChange(raw); /* keep raw text; underlying json becomes invalid */ }
|
|
1108
|
+
}}
|
|
1109
|
+
spellCheck={false}
|
|
1110
|
+
placeholder={type === 'array' ? '[ ]' : '{ }'}
|
|
1111
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1112
|
+
/>
|
|
1113
|
+
{hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
|
|
1114
|
+
</label>
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// string (default) → text input
|
|
1119
|
+
const cur = value == null ? '' : String(value);
|
|
1120
|
+
const isMultiLine = !!hint && hint.length > 80;
|
|
1121
|
+
return (
|
|
1122
|
+
<label className="block">
|
|
1123
|
+
{labelEl}
|
|
1124
|
+
{isMultiLine ? (
|
|
1125
|
+
<textarea
|
|
1126
|
+
rows={2}
|
|
1127
|
+
value={cur}
|
|
1128
|
+
onChange={(e) => onChange(e.target.value || undefined)}
|
|
1129
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1130
|
+
/>
|
|
1131
|
+
) : (
|
|
1132
|
+
<input
|
|
1133
|
+
type="text"
|
|
1134
|
+
value={cur}
|
|
1135
|
+
onChange={(e) => onChange(e.target.value || undefined)}
|
|
1136
|
+
className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1137
|
+
/>
|
|
1138
|
+
)}
|
|
1139
|
+
{hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5 whitespace-pre-wrap">{hint}</div>}
|
|
1140
|
+
</label>
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function Step3({
|
|
1145
|
+
name, onName,
|
|
1146
|
+
trigger, onTrigger,
|
|
1147
|
+
interval, onInterval,
|
|
1148
|
+
cron, onCron,
|
|
1149
|
+
scheduleAt, onScheduleAt,
|
|
1150
|
+
bodyKind, bodyRef,
|
|
1151
|
+
actionKind, onActionKind,
|
|
1152
|
+
chatSessionId, onChatSessionId,
|
|
1153
|
+
chatPrefix, onChatPrefix,
|
|
1154
|
+
chatSessions,
|
|
1155
|
+
emailTo, onEmailTo,
|
|
1156
|
+
emailSubjectTpl, onEmailSubjectTpl,
|
|
1157
|
+
emailBodyTpl, onEmailBodyTpl,
|
|
1158
|
+
telegramChatId, onTelegramChatId,
|
|
1159
|
+
telegramPrefix, onTelegramPrefix,
|
|
1160
|
+
}: {
|
|
1161
|
+
name: string; onName: (v: string) => void;
|
|
1162
|
+
trigger: 'period' | 'cron' | 'once' | 'manual'; onTrigger: (v: 'period' | 'cron' | 'once' | 'manual') => void;
|
|
1163
|
+
interval: number; onInterval: (v: number) => void;
|
|
1164
|
+
cron: string; onCron: (v: string) => void;
|
|
1165
|
+
scheduleAt: string; onScheduleAt: (v: string) => void;
|
|
1166
|
+
bodyKind: BodyKind; bodyRef: string;
|
|
1167
|
+
actionKind: 'none' | 'chat' | 'email' | 'telegram'; onActionKind: (v: 'none' | 'chat' | 'email' | 'telegram') => void;
|
|
1168
|
+
chatSessionId: string; onChatSessionId: (v: string) => void;
|
|
1169
|
+
chatPrefix: string; onChatPrefix: (v: string) => void;
|
|
1170
|
+
chatSessions: Array<{ id: string; title: string }>;
|
|
1171
|
+
emailTo: string; onEmailTo: (v: string) => void;
|
|
1172
|
+
emailSubjectTpl: string; onEmailSubjectTpl: (v: string) => void;
|
|
1173
|
+
emailBodyTpl: string; onEmailBodyTpl: (v: string) => void;
|
|
1174
|
+
telegramChatId: string; onTelegramChatId: (v: string) => void;
|
|
1175
|
+
telegramPrefix: string; onTelegramPrefix: (v: string) => void;
|
|
1176
|
+
}) {
|
|
1177
|
+
return (
|
|
1178
|
+
<div>
|
|
1179
|
+
<div className="text-[11px] text-[var(--text-secondary)] mb-3">Step 3 / 3 — Name, trigger, action.</div>
|
|
1180
|
+
|
|
1181
|
+
<label className="block mb-3">
|
|
1182
|
+
<div className="text-[11px] mb-0.5">Name</div>
|
|
1183
|
+
<input
|
|
1184
|
+
type="text"
|
|
1185
|
+
value={name}
|
|
1186
|
+
onChange={(e) => onName(e.target.value)}
|
|
1187
|
+
placeholder={`${bodyKind} · ${bodyRef}`}
|
|
1188
|
+
className="w-full text-[11px] px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1189
|
+
/>
|
|
1190
|
+
</label>
|
|
1191
|
+
|
|
1192
|
+
<div className="text-[11px] mb-1.5">Trigger</div>
|
|
1193
|
+
<div className="space-y-1.5 mb-3">
|
|
1194
|
+
<Radio label="Every N minutes" v="period" trigger={trigger} onChange={onTrigger}>
|
|
1195
|
+
<input
|
|
1196
|
+
type="number"
|
|
1197
|
+
min={1}
|
|
1198
|
+
value={interval}
|
|
1199
|
+
onChange={(e) => onInterval(Math.max(1, Number(e.target.value) || 1))}
|
|
1200
|
+
disabled={trigger !== 'period'}
|
|
1201
|
+
className="text-[11px] font-mono px-2 py-0.5 w-16 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
|
|
1202
|
+
/>
|
|
1203
|
+
<span className="text-[11px] text-[var(--text-secondary)]">min</span>
|
|
1204
|
+
</Radio>
|
|
1205
|
+
<Radio label="Cron" v="cron" trigger={trigger} onChange={onTrigger}>
|
|
1206
|
+
<input
|
|
1207
|
+
type="text"
|
|
1208
|
+
value={cron}
|
|
1209
|
+
onChange={(e) => onCron(e.target.value)}
|
|
1210
|
+
disabled={trigger !== 'cron'}
|
|
1211
|
+
placeholder="*/15 * * * *"
|
|
1212
|
+
className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
|
|
1213
|
+
/>
|
|
1214
|
+
</Radio>
|
|
1215
|
+
<Radio label="Once at" v="once" trigger={trigger} onChange={onTrigger}>
|
|
1216
|
+
<input
|
|
1217
|
+
type="datetime-local"
|
|
1218
|
+
value={scheduleAt}
|
|
1219
|
+
onChange={(e) => onScheduleAt(e.target.value)}
|
|
1220
|
+
disabled={trigger !== 'once'}
|
|
1221
|
+
className="text-[11px] font-mono px-2 py-0.5 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
|
|
1222
|
+
/>
|
|
1223
|
+
</Radio>
|
|
1224
|
+
<Radio label="Manual only (Fire button)" v="manual" trigger={trigger} onChange={onTrigger}>
|
|
1225
|
+
<span className="text-[10px] text-[var(--text-secondary)]">— won't auto-trigger</span>
|
|
1226
|
+
</Radio>
|
|
1227
|
+
</div>
|
|
1228
|
+
|
|
1229
|
+
<div className="text-[11px] mb-1.5 mt-4">Action (when body finishes)</div>
|
|
1230
|
+
<div className="space-y-1.5 mb-3">
|
|
1231
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
1232
|
+
<input type="radio" name="action" checked={actionKind === 'none'} onChange={() => onActionKind('none')} />
|
|
1233
|
+
<span className="text-[11px] w-44">None</span>
|
|
1234
|
+
<span className="text-[10px] text-[var(--text-secondary)]">— body self-handles output</span>
|
|
1235
|
+
</label>
|
|
1236
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
1237
|
+
<input type="radio" name="action" checked={actionKind === 'chat'} onChange={() => onActionKind('chat')} />
|
|
1238
|
+
<span className="text-[11px] w-44">Send to Chat session</span>
|
|
1239
|
+
<select
|
|
1240
|
+
value={chatSessionId}
|
|
1241
|
+
onChange={(e) => onChatSessionId(e.target.value)}
|
|
1242
|
+
disabled={actionKind !== 'chat'}
|
|
1243
|
+
className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
|
|
1244
|
+
>
|
|
1245
|
+
<option value="">— pick session —</option>
|
|
1246
|
+
{chatSessions.map((s) => (
|
|
1247
|
+
<option key={s.id} value={s.id}>{s.title || s.id.slice(0, 8)}</option>
|
|
1248
|
+
))}
|
|
1249
|
+
</select>
|
|
1250
|
+
</label>
|
|
1251
|
+
{actionKind === 'chat' && (
|
|
1252
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
1253
|
+
<span className="text-[11px] w-44 ml-6">prefix (optional)</span>
|
|
1254
|
+
<input
|
|
1255
|
+
type="text"
|
|
1256
|
+
value={chatPrefix}
|
|
1257
|
+
onChange={(e) => onChatPrefix(e.target.value)}
|
|
1258
|
+
placeholder="📋 Daily report:\n\n"
|
|
1259
|
+
className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1260
|
+
/>
|
|
1261
|
+
</label>
|
|
1262
|
+
)}
|
|
1263
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
1264
|
+
<input type="radio" name="action" checked={actionKind === 'email'} onChange={() => onActionKind('email')} />
|
|
1265
|
+
<span className="text-[11px] w-44">Send Email (SMTP)</span>
|
|
1266
|
+
<input
|
|
1267
|
+
type="text"
|
|
1268
|
+
value={emailTo}
|
|
1269
|
+
onChange={(e) => onEmailTo(e.target.value)}
|
|
1270
|
+
disabled={actionKind !== 'email'}
|
|
1271
|
+
placeholder="to@example.com, …"
|
|
1272
|
+
className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
|
|
1273
|
+
/>
|
|
1274
|
+
</label>
|
|
1275
|
+
{actionKind === 'email' && (
|
|
1276
|
+
<>
|
|
1277
|
+
<label className="flex items-center gap-2">
|
|
1278
|
+
<span className="text-[11px] w-44 ml-6">subject template</span>
|
|
1279
|
+
<input
|
|
1280
|
+
type="text"
|
|
1281
|
+
value={emailSubjectTpl}
|
|
1282
|
+
onChange={(e) => onEmailSubjectTpl(e.target.value)}
|
|
1283
|
+
placeholder="{date} — Forge schedule"
|
|
1284
|
+
className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1285
|
+
/>
|
|
1286
|
+
</label>
|
|
1287
|
+
<label className="flex items-baseline gap-2">
|
|
1288
|
+
<span className="text-[11px] w-44 ml-6">body template</span>
|
|
1289
|
+
<textarea
|
|
1290
|
+
rows={3}
|
|
1291
|
+
value={emailBodyTpl}
|
|
1292
|
+
onChange={(e) => onEmailBodyTpl(e.target.value)}
|
|
1293
|
+
placeholder="{body_output}"
|
|
1294
|
+
className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1295
|
+
/>
|
|
1296
|
+
</label>
|
|
1297
|
+
<div className="text-[10px] text-[var(--text-secondary)] ml-[12.4rem]">
|
|
1298
|
+
Placeholders: {'{date}'} (YYYY-MM-DD) and {'{body_output}'}. SMTP host configured in Settings → SMTP.
|
|
1299
|
+
</div>
|
|
1300
|
+
</>
|
|
1301
|
+
)}
|
|
1302
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
1303
|
+
<input type="radio" name="action" checked={actionKind === 'telegram'} onChange={() => onActionKind('telegram')} />
|
|
1304
|
+
<span className="text-[11px] w-44">Send Telegram</span>
|
|
1305
|
+
<input
|
|
1306
|
+
type="text"
|
|
1307
|
+
value={telegramChatId}
|
|
1308
|
+
onChange={(e) => onTelegramChatId(e.target.value)}
|
|
1309
|
+
disabled={actionKind !== 'telegram'}
|
|
1310
|
+
placeholder="chat id (blank = settings.telegramChatId)"
|
|
1311
|
+
className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
|
|
1312
|
+
/>
|
|
1313
|
+
</label>
|
|
1314
|
+
{actionKind === 'telegram' && (
|
|
1315
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
1316
|
+
<span className="text-[11px] w-44 ml-6">prefix (optional)</span>
|
|
1317
|
+
<input
|
|
1318
|
+
type="text"
|
|
1319
|
+
value={telegramPrefix}
|
|
1320
|
+
onChange={(e) => onTelegramPrefix(e.target.value)}
|
|
1321
|
+
placeholder="📋 Daily bugs:"
|
|
1322
|
+
className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1323
|
+
/>
|
|
1324
|
+
</label>
|
|
1325
|
+
)}
|
|
1326
|
+
</div>
|
|
1327
|
+
</div>
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function Radio({ label, v, trigger, onChange, children }: {
|
|
1332
|
+
label: string;
|
|
1333
|
+
v: 'period' | 'cron' | 'once' | 'manual';
|
|
1334
|
+
trigger: 'period' | 'cron' | 'once' | 'manual';
|
|
1335
|
+
onChange: (v: 'period' | 'cron' | 'once' | 'manual') => void;
|
|
1336
|
+
children?: React.ReactNode;
|
|
1337
|
+
}) {
|
|
1338
|
+
return (
|
|
1339
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
1340
|
+
<input type="radio" name="trigger" value={v} checked={trigger === v} onChange={() => onChange(v)} />
|
|
1341
|
+
<span className="text-[11px] w-44">{label}</span>
|
|
1342
|
+
{children}
|
|
1343
|
+
</label>
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// ─── Extra-skills section (Step 2) ─────────────────────────
|
|
1348
|
+
//
|
|
1349
|
+
// Multi-select for skills the dispatched body should use, in addition
|
|
1350
|
+
// to whatever the body itself loads (body=skill's own /skill ref or a
|
|
1351
|
+
// pipeline node's prompt). Forwarded as --append-system-prompt; Forge
|
|
1352
|
+
// auto-installs missing skills into the target project at dispatch.
|
|
1353
|
+
|
|
1354
|
+
function ExtraSkillsSection({
|
|
1355
|
+
skills, onChange, allSkills, onOpenPicker,
|
|
1356
|
+
}: {
|
|
1357
|
+
skills: string[];
|
|
1358
|
+
onChange: (v: string[]) => void;
|
|
1359
|
+
allSkills: SkillItem[];
|
|
1360
|
+
onOpenPicker: () => void;
|
|
1361
|
+
}) {
|
|
1362
|
+
const empty = allSkills.length === 0;
|
|
1363
|
+
return (
|
|
1364
|
+
<div className="mt-4 pt-3 border-t border-[var(--border)]">
|
|
1365
|
+
<div className="flex items-baseline gap-2 mb-1">
|
|
1366
|
+
<span className="text-[11px] font-semibold">Skills <span className="text-[var(--text-secondary)] font-normal">(optional)</span></span>
|
|
1367
|
+
</div>
|
|
1368
|
+
<div className="text-[10px] text-[var(--text-secondary)] mb-1.5 leading-relaxed">
|
|
1369
|
+
Forge skills the dispatched task should use. Auto-installed into the
|
|
1370
|
+
target project before each run; appended as "use /<skill> as appropriate"
|
|
1371
|
+
to the task's system prompt.
|
|
1372
|
+
</div>
|
|
1373
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
1374
|
+
{skills.length === 0 ? (
|
|
1375
|
+
<span className="text-[10px] text-[var(--text-secondary)]">(none selected)</span>
|
|
1376
|
+
) : (
|
|
1377
|
+
skills.map((name) => (
|
|
1378
|
+
<span
|
|
1379
|
+
key={name}
|
|
1380
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded font-mono bg-[var(--accent)]/15 text-[var(--accent)]"
|
|
1381
|
+
>
|
|
1382
|
+
/{name}
|
|
1383
|
+
<button
|
|
1384
|
+
type="button"
|
|
1385
|
+
onClick={() => onChange(skills.filter((x) => x !== name))}
|
|
1386
|
+
title="Remove"
|
|
1387
|
+
className="leading-none text-[11px] hover:opacity-70"
|
|
1388
|
+
>×</button>
|
|
1389
|
+
</span>
|
|
1390
|
+
))
|
|
1391
|
+
)}
|
|
1392
|
+
<button
|
|
1393
|
+
type="button"
|
|
1394
|
+
onClick={onOpenPicker}
|
|
1395
|
+
disabled={empty}
|
|
1396
|
+
title={empty ? 'No skills synced yet — open the Skills tab' : 'Pick skills'}
|
|
1397
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)] disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1398
|
+
>
|
|
1399
|
+
{empty ? 'No skills synced' : skills.length > 0 ? '+ Add / edit' : '+ Pick skills'}
|
|
1400
|
+
</button>
|
|
1401
|
+
</div>
|
|
1402
|
+
</div>
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Centered modal — search box + scrollable list of toggles. Clicking
|
|
1407
|
+
// outside / Esc closes. Mirrors the extension JobsTab's SkillPicker
|
|
1408
|
+
// shape so behavior is consistent across surfaces.
|
|
1409
|
+
function MultiSkillPickerModal({
|
|
1410
|
+
all, selected, onClose, onChange,
|
|
1411
|
+
}: {
|
|
1412
|
+
all: SkillItem[];
|
|
1413
|
+
selected: string[];
|
|
1414
|
+
onClose: () => void;
|
|
1415
|
+
onChange: (v: string[]) => void;
|
|
1416
|
+
}) {
|
|
1417
|
+
const [q, setQ] = useState('');
|
|
1418
|
+
useEffect(() => {
|
|
1419
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
1420
|
+
window.addEventListener('keydown', onKey);
|
|
1421
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
1422
|
+
}, [onClose]);
|
|
1423
|
+
|
|
1424
|
+
const filtered = (() => {
|
|
1425
|
+
const needle = q.trim().toLowerCase();
|
|
1426
|
+
if (!needle) return all;
|
|
1427
|
+
return all.filter((s) => {
|
|
1428
|
+
const hay = `${s.name} ${s.displayName || ''} ${s.description || ''}`.toLowerCase();
|
|
1429
|
+
return hay.includes(needle);
|
|
1430
|
+
});
|
|
1431
|
+
})();
|
|
1432
|
+
|
|
1433
|
+
function toggle(name: string) {
|
|
1434
|
+
onChange(selected.includes(name) ? selected.filter((x) => x !== name) : [...selected, name]);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
return (
|
|
1438
|
+
<div
|
|
1439
|
+
onClick={onClose}
|
|
1440
|
+
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/45"
|
|
1441
|
+
>
|
|
1442
|
+
<div
|
|
1443
|
+
onClick={(e) => e.stopPropagation()}
|
|
1444
|
+
className="bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-2xl w-[520px] max-w-[92vw] max-h-[80vh] flex flex-col"
|
|
1445
|
+
>
|
|
1446
|
+
<div className="flex items-center px-3 py-2 border-b border-[var(--border)]">
|
|
1447
|
+
<div className="text-[12px] font-semibold flex-1">
|
|
1448
|
+
Pick skills
|
|
1449
|
+
<span className="ml-2 text-[10px] font-normal text-[var(--text-secondary)]">
|
|
1450
|
+
{selected.length} of {all.length} selected
|
|
1451
|
+
</span>
|
|
1452
|
+
</div>
|
|
1453
|
+
<button onClick={onClose} className="text-[11px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]">Done</button>
|
|
1454
|
+
</div>
|
|
1455
|
+
<div className="px-3 py-2 border-b border-[var(--border)]">
|
|
1456
|
+
<input
|
|
1457
|
+
autoFocus
|
|
1458
|
+
value={q}
|
|
1459
|
+
onChange={(e) => setQ(e.target.value)}
|
|
1460
|
+
placeholder="Search by name or description…"
|
|
1461
|
+
className="w-full text-[12px] px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1462
|
+
/>
|
|
1463
|
+
{selected.length > 0 && (
|
|
1464
|
+
<div className="flex items-center gap-1.5 flex-wrap mt-1.5">
|
|
1465
|
+
<span className="text-[10px] text-[var(--text-secondary)]">Selected:</span>
|
|
1466
|
+
{selected.map((name) => (
|
|
1467
|
+
<span key={name} className="px-1.5 py-px text-[10px] rounded font-mono bg-[var(--accent)]/15 text-[var(--accent)]">/{name}</span>
|
|
1468
|
+
))}
|
|
1469
|
+
<button
|
|
1470
|
+
type="button"
|
|
1471
|
+
onClick={() => onChange([])}
|
|
1472
|
+
className="ml-auto text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]"
|
|
1473
|
+
>Clear all</button>
|
|
1474
|
+
</div>
|
|
1475
|
+
)}
|
|
1476
|
+
</div>
|
|
1477
|
+
<div className="overflow-auto flex-1">
|
|
1478
|
+
{filtered.length === 0 ? (
|
|
1479
|
+
<div className="text-[11px] text-[var(--text-secondary)] text-center py-6">No skills match "{q}"</div>
|
|
1480
|
+
) : (
|
|
1481
|
+
filtered.map((s) => {
|
|
1482
|
+
const isSel = selected.includes(s.name);
|
|
1483
|
+
return (
|
|
1484
|
+
<label
|
|
1485
|
+
key={s.name}
|
|
1486
|
+
onClick={() => toggle(s.name)}
|
|
1487
|
+
className={`flex items-start gap-2 px-3 py-1.5 border-b border-[var(--border)] cursor-pointer hover:bg-[var(--bg-secondary)] ${isSel ? 'bg-[var(--accent)]/10' : ''}`}
|
|
1488
|
+
>
|
|
1489
|
+
<input type="checkbox" checked={isSel} readOnly className="mt-1" />
|
|
1490
|
+
<div className="flex-1 min-w-0">
|
|
1491
|
+
<div className="text-[11px] font-mono">{s.name}</div>
|
|
1492
|
+
{s.description && (
|
|
1493
|
+
<div className="text-[10px] text-[var(--text-secondary)] truncate">{s.description}</div>
|
|
1494
|
+
)}
|
|
1495
|
+
</div>
|
|
1496
|
+
{(s.installedGlobal || (s.installedProjects?.length ?? 0) > 0) && (
|
|
1497
|
+
<span className="text-[9px] text-[var(--text-secondary)] mt-0.5">installed</span>
|
|
1498
|
+
)}
|
|
1499
|
+
</label>
|
|
1500
|
+
);
|
|
1501
|
+
})
|
|
1502
|
+
)}
|
|
1503
|
+
</div>
|
|
1504
|
+
</div>
|
|
1505
|
+
</div>
|
|
1506
|
+
);
|
|
1507
|
+
}
|