@aion0/forge 0.9.12 → 0.9.14
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 +8 -6
- package/app/api/prompts/[name]/route.ts +37 -0
- package/app/api/prompts/route.ts +35 -0
- package/app/api/schedules/extract/route.ts +184 -0
- package/app/api/schedules/route.ts +14 -37
- package/components/ScheduleCreateModal.tsx +237 -537
- package/components/ScheduleQuickCreate.tsx +404 -0
- package/components/SchedulesView.tsx +18 -6
- package/lib/chat/agent-loop.ts +24 -0
- package/lib/chat/llm/anthropic.ts +128 -78
- package/lib/chat/llm/openai.ts +75 -180
- package/lib/chat/tool-dispatcher.ts +221 -0
- package/lib/forge-mcp-server.ts +84 -0
- package/lib/init.ts +7 -0
- package/lib/pipeline.ts +19 -0
- package/lib/projects.ts +67 -2
- package/lib/prompts/store.ts +142 -0
- package/lib/prompts/types.ts +53 -0
- package/lib/schedules/scheduler.ts +51 -143
- package/lib/schedules/store.ts +6 -15
- package/lib/schedules/types.ts +10 -14
- package/package.json +1 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ScheduleQuickCreate — V3 D entry point (#266).
|
|
5
|
+
*
|
|
6
|
+
* Two stages:
|
|
7
|
+
* 1. Natural-language textarea → POST /api/schedules/extract
|
|
8
|
+
* 2. ScheduleConfirmCard renders the parsed spec, lets user adjust
|
|
9
|
+
* fields inline, then POSTs to /api/prompts + /api/schedules.
|
|
10
|
+
*
|
|
11
|
+
* Always half-automatic — Forge never auto-creates the schedule. The
|
|
12
|
+
* user must Confirm. Low confidence (< 0.7) or non-empty `missing[]`
|
|
13
|
+
* shows a banner asking them to verify before saving.
|
|
14
|
+
*
|
|
15
|
+
* Chat-thread integration (extractor invoked mid-chat with a Confirm Card
|
|
16
|
+
* inline) is a follow-up — same Card component will be reused there.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect, useState } from 'react';
|
|
20
|
+
|
|
21
|
+
interface Extracted {
|
|
22
|
+
name: string;
|
|
23
|
+
prompt_name: string;
|
|
24
|
+
prompt: string;
|
|
25
|
+
agent: string | null;
|
|
26
|
+
skills: string[];
|
|
27
|
+
project: string | null;
|
|
28
|
+
trigger: {
|
|
29
|
+
kind: 'period' | 'cron' | 'once' | 'manual';
|
|
30
|
+
interval_minutes: number | null;
|
|
31
|
+
cron: string | null;
|
|
32
|
+
at_iso: string | null;
|
|
33
|
+
};
|
|
34
|
+
action: {
|
|
35
|
+
kind: 'none' | 'chat' | 'email' | 'telegram';
|
|
36
|
+
config: Record<string, unknown>;
|
|
37
|
+
};
|
|
38
|
+
confidence: number;
|
|
39
|
+
missing: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AgentInfo { id: string; label?: string; backendType?: string }
|
|
43
|
+
interface ProjectInfo { name: string; path: string }
|
|
44
|
+
|
|
45
|
+
interface Props {
|
|
46
|
+
onClose: () => void;
|
|
47
|
+
onCreated: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function ScheduleQuickCreate({ onClose, onCreated }: Props) {
|
|
51
|
+
const [text, setText] = useState('');
|
|
52
|
+
const [extracting, setExtracting] = useState(false);
|
|
53
|
+
const [extracted, setExtracted] = useState<Extracted | null>(null);
|
|
54
|
+
const [err, setErr] = useState('');
|
|
55
|
+
|
|
56
|
+
async function extract() {
|
|
57
|
+
setErr('');
|
|
58
|
+
setExtracting(true);
|
|
59
|
+
try {
|
|
60
|
+
const r = await fetch('/api/schedules/extract', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'content-type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ text: text.trim() }),
|
|
64
|
+
});
|
|
65
|
+
const j = await r.json();
|
|
66
|
+
if (!r.ok || !j.ok) {
|
|
67
|
+
setErr(j.error || `HTTP ${r.status}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
setExtracted(j.extracted as Extracted);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
73
|
+
} finally {
|
|
74
|
+
setExtracting(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[10vh]">
|
|
80
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-2xl w-[680px] max-w-[95vw] max-h-[85vh] flex flex-col">
|
|
81
|
+
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center">
|
|
82
|
+
<h2 className="text-[13px] font-semibold flex-1">✨ Quick create schedule</h2>
|
|
83
|
+
<button onClick={onClose} className="text-[14px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] w-6 h-6">✕</button>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className="flex-1 overflow-auto p-4">
|
|
87
|
+
{!extracted && (
|
|
88
|
+
<>
|
|
89
|
+
<div className="text-[11px] text-[var(--text-secondary)] mb-2">
|
|
90
|
+
Describe what you want Forge to run on a schedule. Be specific about
|
|
91
|
+
<em> when</em>, <em>what</em>, and <em>where to send the result</em>.
|
|
92
|
+
Forge will parse it into a draft you can review and tweak before saving.
|
|
93
|
+
</div>
|
|
94
|
+
<textarea
|
|
95
|
+
value={text}
|
|
96
|
+
onChange={(e) => setText(e.target.value)}
|
|
97
|
+
rows={6}
|
|
98
|
+
placeholder="e.g. 每天上午 9 点查一下我今天分到的 mantis bug,发我 Telegram"
|
|
99
|
+
className="w-full text-[12px] font-mono px-3 py-2 border border-[var(--border)] rounded bg-[var(--bg-secondary)] resize-y"
|
|
100
|
+
/>
|
|
101
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-1">
|
|
102
|
+
Your wording stays verbatim — Forge does NOT rewrite it.
|
|
103
|
+
</div>
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{extracted && (
|
|
108
|
+
<ScheduleConfirmCard
|
|
109
|
+
initial={extracted}
|
|
110
|
+
originalText={text}
|
|
111
|
+
onCancel={() => setExtracted(null)}
|
|
112
|
+
onCreated={onCreated}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2 mt-3">{err}</div>}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{!extracted && (
|
|
120
|
+
<div className="px-4 py-3 border-t border-[var(--border)] flex items-center justify-between">
|
|
121
|
+
<button onClick={onClose} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
|
|
122
|
+
<button
|
|
123
|
+
onClick={() => void extract()}
|
|
124
|
+
disabled={!text.trim() || extracting}
|
|
125
|
+
className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
|
|
126
|
+
>{extracting ? 'Parsing…' : 'Parse →'}</button>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ScheduleConfirmCard({
|
|
135
|
+
initial, originalText, onCancel, onCreated,
|
|
136
|
+
}: {
|
|
137
|
+
initial: Extracted;
|
|
138
|
+
originalText: string;
|
|
139
|
+
onCancel: () => void;
|
|
140
|
+
onCreated: () => void;
|
|
141
|
+
}) {
|
|
142
|
+
const [name, setName] = useState(initial.name || initial.prompt_name || 'untitled');
|
|
143
|
+
const [promptText, setPromptText] = useState(initial.prompt);
|
|
144
|
+
const [agent, setAgent] = useState(initial.agent || '');
|
|
145
|
+
const [skills, setSkills] = useState<string[]>(initial.skills);
|
|
146
|
+
const [skillsRaw, setSkillsRaw] = useState(initial.skills.join(', '));
|
|
147
|
+
const [project, setProject] = useState(initial.project || '');
|
|
148
|
+
const [triggerKind, setTriggerKind] = useState(initial.trigger.kind);
|
|
149
|
+
const [interval, setInterval] = useState(initial.trigger.interval_minutes ?? 30);
|
|
150
|
+
const [cron, setCron] = useState(initial.trigger.cron ?? '');
|
|
151
|
+
const [atIso, setAtIso] = useState(initial.trigger.at_iso ?? '');
|
|
152
|
+
const [actionKind, setActionKind] = useState(initial.action.kind);
|
|
153
|
+
const [actionConfigJson, setActionConfigJson] = useState(
|
|
154
|
+
JSON.stringify(initial.action.config ?? {}, null, 2)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
158
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
159
|
+
const [submitting, setSubmitting] = useState(false);
|
|
160
|
+
const [err, setErr] = useState('');
|
|
161
|
+
|
|
162
|
+
const lowConfidence = initial.confidence < 0.7 || initial.missing.length > 0;
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
(async () => {
|
|
166
|
+
try {
|
|
167
|
+
const [skR, agR] = await Promise.all([
|
|
168
|
+
fetch('/api/skills').then((r) => r.json()),
|
|
169
|
+
fetch('/api/agents?include=all').then((r) => r.json()).catch(() => ({ agents: [] })),
|
|
170
|
+
]);
|
|
171
|
+
if (Array.isArray(skR?.projects)) setProjects(skR.projects);
|
|
172
|
+
if (Array.isArray(agR?.agents)) setAgents(agR.agents);
|
|
173
|
+
} catch {/* ignore — fields fall back to free text */}
|
|
174
|
+
})();
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
async function submit() {
|
|
178
|
+
setErr('');
|
|
179
|
+
setSubmitting(true);
|
|
180
|
+
try {
|
|
181
|
+
const cleanedSkills = skillsRaw
|
|
182
|
+
.split(/[,\s]+/)
|
|
183
|
+
.map((s) => s.trim())
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
// 1. Create prompts/<name>.yaml. Auto-suffix on 409 (same logic as
|
|
186
|
+
// ScheduleCreateModal — keeps quick-create idempotent across retries).
|
|
187
|
+
const base = slugifyName(name) || initial.prompt_name || `prompt-${Date.now().toString(36).slice(-6)}`;
|
|
188
|
+
let promptNameToUse: string | null = null;
|
|
189
|
+
let candidate = base;
|
|
190
|
+
for (let i = 2; i <= 50; i++) {
|
|
191
|
+
const r = await fetch('/api/prompts', {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'content-type': 'application/json' },
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
name: candidate,
|
|
196
|
+
prompt: promptText,
|
|
197
|
+
executor: { agent: agent || null, skills: cleanedSkills },
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
if (r.ok) { promptNameToUse = candidate; break; }
|
|
201
|
+
if (r.status === 409) { candidate = `${base}-${i}`; continue; }
|
|
202
|
+
const j = await r.json().catch(() => ({}));
|
|
203
|
+
setErr(j.error || `prompt save failed (${r.status})`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!promptNameToUse) {
|
|
207
|
+
setErr(`couldn't find a free prompt name starting from "${base}"`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 2. Create the schedule
|
|
212
|
+
let actionConfig: Record<string, unknown>;
|
|
213
|
+
try {
|
|
214
|
+
actionConfig = actionConfigJson.trim() ? JSON.parse(actionConfigJson) : {};
|
|
215
|
+
} catch (e) {
|
|
216
|
+
setErr(`action_config is not valid JSON: ${(e as Error).message}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const body: Record<string, unknown> = {
|
|
220
|
+
name,
|
|
221
|
+
body_kind: 'prompt',
|
|
222
|
+
body_ref: promptNameToUse,
|
|
223
|
+
input: { project },
|
|
224
|
+
enabled: true,
|
|
225
|
+
schedule_kind: triggerKind,
|
|
226
|
+
schedule_interval_minutes: triggerKind === 'period' ? interval : null,
|
|
227
|
+
schedule_cron: triggerKind === 'cron' ? cron.trim() : null,
|
|
228
|
+
schedule_at: triggerKind === 'once' && atIso ? new Date(atIso).toISOString() : null,
|
|
229
|
+
action_kind: actionKind,
|
|
230
|
+
action_config: actionConfig,
|
|
231
|
+
};
|
|
232
|
+
const r = await fetch('/api/schedules', {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'content-type': 'application/json' },
|
|
235
|
+
body: JSON.stringify(body),
|
|
236
|
+
});
|
|
237
|
+
if (!r.ok) {
|
|
238
|
+
const j = await r.json().catch(() => ({}));
|
|
239
|
+
setErr(j.error || `schedule create failed (${r.status})`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
onCreated();
|
|
243
|
+
} catch (e) {
|
|
244
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
245
|
+
} finally {
|
|
246
|
+
setSubmitting(false);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="space-y-3">
|
|
252
|
+
<div className="text-[11px] text-[var(--text-secondary)]">
|
|
253
|
+
Forge parsed your request below. Review, tweak anything, then Create.
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{lowConfidence && (
|
|
257
|
+
<div className="text-[11px] text-[var(--yellow)] bg-[var(--yellow)]/10 rounded p-2 border border-[var(--yellow)]/30">
|
|
258
|
+
⚠ confidence {initial.confidence.toFixed(2)}
|
|
259
|
+
{initial.missing.length > 0 && <> · missing: <code className="font-mono">{initial.missing.join(', ')}</code></>}
|
|
260
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">Verify the fields before saving.</div>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
<Field label="Schedule name">
|
|
265
|
+
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
|
|
266
|
+
</Field>
|
|
267
|
+
|
|
268
|
+
<Field label="Prompt (verbatim)" hint="The agent will receive this exact text. Tweak if Forge mis-quoted.">
|
|
269
|
+
<textarea
|
|
270
|
+
value={promptText}
|
|
271
|
+
onChange={(e) => setPromptText(e.target.value)}
|
|
272
|
+
rows={5}
|
|
273
|
+
className={inputCls + ' font-mono'}
|
|
274
|
+
/>
|
|
275
|
+
</Field>
|
|
276
|
+
|
|
277
|
+
<div className="grid grid-cols-2 gap-3">
|
|
278
|
+
<Field label="Project" hint="Leave on scratch for prompts that just call connectors.">
|
|
279
|
+
<select value={project} onChange={(e) => setProject(e.target.value)} className={inputCls}>
|
|
280
|
+
<option value="">— scratch (default) —</option>
|
|
281
|
+
{projects.filter((p) => p.name !== 'scratch').map((p) => <option key={p.name} value={p.name}>{p.name}</option>)}
|
|
282
|
+
{project && project !== 'scratch' && !projects.some((p) => p.name === project) && (
|
|
283
|
+
<option value={project}>{project} (not in projects list)</option>
|
|
284
|
+
)}
|
|
285
|
+
</select>
|
|
286
|
+
</Field>
|
|
287
|
+
<Field label="Agent">
|
|
288
|
+
<select value={agent} onChange={(e) => setAgent(e.target.value)} className={inputCls}>
|
|
289
|
+
<option value="">— system default —</option>
|
|
290
|
+
{agents.map((a) => <option key={a.id} value={a.id}>{a.label || a.id}</option>)}
|
|
291
|
+
</select>
|
|
292
|
+
</Field>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<Field label="Skills" hint="Comma-separated. ai-orchestration is the default V3 skill (lets the agent call connectors).">
|
|
296
|
+
<input value={skillsRaw} onChange={(e) => setSkillsRaw(e.target.value)} className={inputCls + ' font-mono'} />
|
|
297
|
+
</Field>
|
|
298
|
+
|
|
299
|
+
<Field label="Trigger">
|
|
300
|
+
<div className="flex gap-2 items-baseline">
|
|
301
|
+
<select
|
|
302
|
+
value={triggerKind}
|
|
303
|
+
onChange={(e) => setTriggerKind(e.target.value as Extracted['trigger']['kind'])}
|
|
304
|
+
className={inputCls + ' w-32'}
|
|
305
|
+
>
|
|
306
|
+
<option value="period">period</option>
|
|
307
|
+
<option value="cron">cron</option>
|
|
308
|
+
<option value="once">once</option>
|
|
309
|
+
<option value="manual">manual</option>
|
|
310
|
+
</select>
|
|
311
|
+
{triggerKind === 'period' && (
|
|
312
|
+
<>
|
|
313
|
+
<input
|
|
314
|
+
type="number" min={1}
|
|
315
|
+
value={interval}
|
|
316
|
+
onChange={(e) => setInterval(Number(e.target.value) || 1)}
|
|
317
|
+
className={inputCls + ' w-24'}
|
|
318
|
+
/>
|
|
319
|
+
<span className="text-[10px] text-[var(--text-secondary)]">minutes</span>
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
{triggerKind === 'cron' && (
|
|
323
|
+
<input value={cron} onChange={(e) => setCron(e.target.value)} placeholder="e.g. 0 9 * * *"
|
|
324
|
+
className={inputCls + ' font-mono flex-1'} />
|
|
325
|
+
)}
|
|
326
|
+
{triggerKind === 'once' && (
|
|
327
|
+
<input
|
|
328
|
+
type="datetime-local"
|
|
329
|
+
value={atIso ? new Date(atIso).toISOString().slice(0, 16) : ''}
|
|
330
|
+
onChange={(e) => setAtIso(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
|
331
|
+
className={inputCls + ' flex-1'}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
</Field>
|
|
336
|
+
|
|
337
|
+
<Field label="Action">
|
|
338
|
+
<div className="flex gap-2 items-start">
|
|
339
|
+
<select
|
|
340
|
+
value={actionKind}
|
|
341
|
+
onChange={(e) => setActionKind(e.target.value as Extracted['action']['kind'])}
|
|
342
|
+
className={inputCls + ' w-32'}
|
|
343
|
+
>
|
|
344
|
+
<option value="none">none</option>
|
|
345
|
+
<option value="chat">chat</option>
|
|
346
|
+
<option value="email">email</option>
|
|
347
|
+
<option value="telegram">telegram</option>
|
|
348
|
+
</select>
|
|
349
|
+
{actionKind !== 'none' && (
|
|
350
|
+
<textarea
|
|
351
|
+
value={actionConfigJson}
|
|
352
|
+
onChange={(e) => setActionConfigJson(e.target.value)}
|
|
353
|
+
rows={3}
|
|
354
|
+
placeholder='{ "chat_id": "..." }'
|
|
355
|
+
className={inputCls + ' font-mono flex-1'}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
{actionKind !== 'none' && (
|
|
360
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-1">
|
|
361
|
+
JSON object; see Schedules help doc for the per-action shape. Chat needs <code className="font-mono">session_id</code>, email needs <code className="font-mono">to</code>, telegram needs <code className="font-mono">chat_id</code> (or default subscriber).
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
</Field>
|
|
365
|
+
|
|
366
|
+
{err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2">{err}</div>}
|
|
367
|
+
|
|
368
|
+
<details className="text-[10px] text-[var(--text-secondary)]">
|
|
369
|
+
<summary className="cursor-pointer">Original text</summary>
|
|
370
|
+
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{originalText}</pre>
|
|
371
|
+
</details>
|
|
372
|
+
|
|
373
|
+
<div className="flex justify-between pt-2 border-t border-[var(--border)]">
|
|
374
|
+
<button onClick={onCancel} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">← Re-parse</button>
|
|
375
|
+
<button
|
|
376
|
+
onClick={() => void submit()}
|
|
377
|
+
disabled={submitting || !promptText.trim() || !name.trim() || (triggerKind === 'cron' && !cron.trim()) || (triggerKind === 'once' && !atIso)}
|
|
378
|
+
className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
|
|
379
|
+
>{submitting ? 'Creating…' : 'Confirm create'}</button>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const inputCls = 'text-[11px] px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] w-full';
|
|
386
|
+
|
|
387
|
+
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
388
|
+
return (
|
|
389
|
+
<div>
|
|
390
|
+
<div className="text-[11px] mb-0.5">{label}</div>
|
|
391
|
+
{children}
|
|
392
|
+
{hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Mirror the slug rule in ScheduleCreateModal so quick-create + manual create
|
|
398
|
+
// produce comparable prompt filenames.
|
|
399
|
+
function slugifyName(s: string): string {
|
|
400
|
+
return s.toLowerCase()
|
|
401
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
402
|
+
.replace(/^-+|-+$/g, '')
|
|
403
|
+
.slice(0, 50);
|
|
404
|
+
}
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
import { useCallback, useEffect, useState } from 'react';
|
|
16
16
|
import ScheduleCreateModal from './ScheduleCreateModal';
|
|
17
|
+
import ScheduleQuickCreate from './ScheduleQuickCreate';
|
|
17
18
|
|
|
18
19
|
interface Schedule {
|
|
19
20
|
id: string;
|
|
20
21
|
name: string;
|
|
21
22
|
enabled: boolean;
|
|
22
|
-
body_kind: 'pipeline' | '
|
|
23
|
+
body_kind: 'pipeline' | 'prompt';
|
|
23
24
|
body_ref: string;
|
|
24
25
|
input: Record<string, unknown>;
|
|
25
26
|
skills: string[];
|
|
@@ -66,6 +67,7 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
66
67
|
const [runsBySchedule, setRunsBySchedule] = useState<Record<string, ScheduleRun[]>>({});
|
|
67
68
|
const [filter, setFilter] = useState<Filter>('all');
|
|
68
69
|
const [showCreate, setShowCreate] = useState(false);
|
|
70
|
+
const [showQuickCreate, setShowQuickCreate] = useState(false);
|
|
69
71
|
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
|
70
72
|
|
|
71
73
|
const refresh = useCallback(async () => {
|
|
@@ -190,6 +192,11 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
190
192
|
</p>
|
|
191
193
|
</div>
|
|
192
194
|
<div className="flex items-center gap-2">
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => setShowQuickCreate(true)}
|
|
197
|
+
className="text-[10px] px-3 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)]/10"
|
|
198
|
+
title="Describe in natural language; Forge parses + you confirm"
|
|
199
|
+
>✨ Quick create</button>
|
|
193
200
|
<button
|
|
194
201
|
onClick={() => setShowCreate(true)}
|
|
195
202
|
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold hover:opacity-90"
|
|
@@ -252,6 +259,12 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
252
259
|
onCreated={() => { setShowCreate(false); void refresh(); }}
|
|
253
260
|
/>
|
|
254
261
|
)}
|
|
262
|
+
{showQuickCreate && (
|
|
263
|
+
<ScheduleQuickCreate
|
|
264
|
+
onClose={() => setShowQuickCreate(false)}
|
|
265
|
+
onCreated={() => { setShowQuickCreate(false); void refresh(); }}
|
|
266
|
+
/>
|
|
267
|
+
)}
|
|
255
268
|
{editingSchedule && (
|
|
256
269
|
<ScheduleCreateModal
|
|
257
270
|
existing={editingSchedule}
|
|
@@ -503,10 +516,9 @@ function RunRow({ run: r, isPipelineBody, onViewPipeline }: {
|
|
|
503
516
|
isPipelineBody: boolean;
|
|
504
517
|
onViewPipeline?: (pipelineId: string) => void;
|
|
505
518
|
}) {
|
|
506
|
-
// body_output/error captured for
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
// to expanded so the user doesn't have to hunt.
|
|
519
|
+
// body_output/error captured for prompt runs aren't navigable to a
|
|
520
|
+
// pipeline detail page; the only place to surface them is right here,
|
|
521
|
+
// inline. Failed runs default to expanded so the user doesn't have to hunt.
|
|
510
522
|
// Surface ALL three stages — trigger / body / action — instead of
|
|
511
523
|
// just body status. Skipped or failed action was previously
|
|
512
524
|
// invisible (e.g. "body status was started" race where action got
|
|
@@ -514,7 +526,7 @@ function RunRow({ run: r, isPipelineBody, onViewPipeline }: {
|
|
|
514
526
|
// to see WHY chat/email/telegram never fired).
|
|
515
527
|
const actionFailedOrSkipped = r.action_status === 'failed' || r.action_status === 'skipped';
|
|
516
528
|
const [expanded, setExpanded] = useState(r.status === 'failed' || actionFailedOrSkipped);
|
|
517
|
-
const canOpenPipeline = isPipelineBody && !!onViewPipeline
|
|
529
|
+
const canOpenPipeline = isPipelineBody && !!onViewPipeline;
|
|
518
530
|
const hasDetails = !!(r.body_output || r.error || r.action_error);
|
|
519
531
|
|
|
520
532
|
return (
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -164,6 +164,18 @@ function resolveProvider(sessionProvider: string | null, sessionModel: string |
|
|
|
164
164
|
|
|
165
165
|
function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTIN_TOOL_DEFS, sessionSystemPrompt: string | null): string {
|
|
166
166
|
const now = new Date().toISOString();
|
|
167
|
+
|
|
168
|
+
// Inject a brief Forge context block (project names only) so the LLM can
|
|
169
|
+
// validate names the user mentions ("FortiNAC" → real project? → yes) and
|
|
170
|
+
// pass them to trigger_pipeline / dispatch_task without guessing. Full
|
|
171
|
+
// details (paths, agents, skills) are behind list_forge_context — only
|
|
172
|
+
// names are cheap enough to ship every turn.
|
|
173
|
+
let projectNames: string[] = [];
|
|
174
|
+
try {
|
|
175
|
+
const { scanProjects } = require('../projects') as typeof import('../projects');
|
|
176
|
+
projectNames = scanProjects().map((p) => p.name);
|
|
177
|
+
} catch { /* projects roots not configured / read failed — omit */ }
|
|
178
|
+
|
|
167
179
|
const lines: string[] = [
|
|
168
180
|
"You are Forge, the user's personal AI assistant.",
|
|
169
181
|
'',
|
|
@@ -174,6 +186,14 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
|
|
|
174
186
|
' Don\'t explain how to do something manually before trying the tool. The tools below run inside the user\'s actual logged-in browser session — they CAN do things you might think only the user can do manually.',
|
|
175
187
|
'- For Teams in particular: send_message can target any chat by name; if the chat doesn\'t exist yet, the tool will return a specific error and THEN you can advise. Don\'t pre-judge.',
|
|
176
188
|
'- If a tool call fails, read its error carefully — it usually tells you what to fix (wrong arg, missing setting, login required). Retry with the fix. Only give up after the tool explicitly says it cannot do the task.',
|
|
189
|
+
'- For trigger_pipeline / dispatch_task: when the user names a "project" (e.g. "FortiNAC"), pass it as input.project verbatim. The names in the "Forge projects" list below ARE the valid values. Call list_forge_context only if you need paths / agents / skills.',
|
|
190
|
+
'',
|
|
191
|
+
'trigger_pipeline specifics — these are easy to get wrong, READ CAREFULLY:',
|
|
192
|
+
'- FIRST call this session: call trigger_pipeline() with NO arguments. The response lists every workflow + which input fields are required (*) vs have defaults. Field names are EXACT, snake_case (e.g. bug_id), declared by the workflow yaml. They are NOT the same as bash variable names inside pipeline scripts (BUG_ID / BASE / PROJECT_PATH are wrong). DO NOT pass uppercase / made-up names.',
|
|
193
|
+
'- For optional fields with defaults (mr_body_template / user_prompt / teams_message_template / etc.), OMIT them — let the default apply. NEVER pass empty strings or invented placeholder values.',
|
|
194
|
+
'- If the response says "Unknown input fields", "Missing required", or "0 iterations" — the pipeline did NOT do what the user asked. Fix the input and retry. Optionally save a pinned memory rule via memory_remember_block({pinned: true, ...}) so the lesson sticks for future sessions.',
|
|
195
|
+
'- DO NOT trust earlier assistant messages in this conversation that claim a pipeline "already ran" — those may be wrong. If the user re-asks, fire fresh; verify only by re-checking the actual target system (e.g. mantis.get_bug for status).',
|
|
196
|
+
'',
|
|
177
197
|
'- Reply without tools ONLY when no system + no time question is involved.',
|
|
178
198
|
'',
|
|
179
199
|
'Other:',
|
|
@@ -181,6 +201,10 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
|
|
|
181
201
|
'Keep replies short and direct.',
|
|
182
202
|
];
|
|
183
203
|
|
|
204
|
+
if (projectNames.length > 0) {
|
|
205
|
+
lines.push('', `Forge projects (valid input.project values): ${projectNames.join(', ')}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
184
208
|
if (connectorTools.length > 0) {
|
|
185
209
|
lines.push('', 'Connector tools available:');
|
|
186
210
|
for (const t of connectorTools) {
|