@swarmclawai/swarmclaw 1.5.52 → 1.5.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/package.json +1 -1
- package/src/app/api/missions/templates/[id]/instantiate/route.ts +64 -0
- package/src/app/api/missions/templates/route.ts +8 -0
- package/src/app/missions/page.tsx +97 -14
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/agents/inspector-panel.tsx +8 -6
- package/src/components/missions/mission-template-gallery.tsx +113 -0
- package/src/components/missions/mission-template-install-dialog.tsx +278 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +25 -14
- package/src/lib/server/chats/chat-session-service.ts +11 -0
- package/src/lib/server/missions/mission-service.ts +47 -1
- package/src/lib/server/missions/mission-templates.test.ts +208 -0
- package/src/lib/server/missions/mission-templates.ts +186 -0
- package/src/lib/server/storage-normalization.ts +6 -0
- package/src/types/mission.ts +27 -0
package/README.md
CHANGED
|
@@ -399,6 +399,18 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.5.54 Highlights
|
|
403
|
+
|
|
404
|
+
- **Mission templates library**: the `/missions` page now opens with a curated gallery of starter missions. Each template pre-wires a goal, success criteria, USD / token / turn / wallclock budgets, and a report cadence, so non-technical users can install a working autonomous run in one click. Initial lineup: Daily News Digest, Inbox Triage, Competitor Watch, Weekly Research Report, Social Listener, and Customer Support Triage. Setup notes flag any connector or permission prerequisites before installation. Power-user overrides (budget caps, success criteria, report cadence) live behind a collapsed **Advanced Settings** panel so the default install flow stays one click.
|
|
405
|
+
- **New API routes `GET /api/missions/templates` and `POST /api/missions/templates/:id/instantiate`** with matching CLI commands `swarmclaw missions templates` and `swarmclaw missions instantiate`. Installed missions persist a `templateId` so the origin is traceable for future template-update flows; legacy missions normalize to `templateId: null` on load, no data migration required.
|
|
406
|
+
- **Fix: user-selected provider and model now survive the chat execution pipeline** ([#51](https://github.com/swarmclawai/swarmclaw/pull/51), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). Switching provider or model via the inspector panel mid-session was being reverted on every turn because the agent's configured route was unconditionally reapplied in three places. `syncSessionFromAgent` now only syncs credentials / endpoint / fallbacks when the session's provider still matches the route provider, `prepareChatTurn` preserves the user's chosen model after applying the route, and `updateChatSession` auto-resolves a stored credential for the new provider (and clears the stale `apiEndpoint`) when provider changes without an explicit `credentialId`. Restores reliable switching between Copilot CLI, Codex CLI, Groq, and OpenAI-compatible providers.
|
|
407
|
+
|
|
408
|
+
> **Note:** v1.5.53 release notes described the mission templates library, but the feature commit landed after the v1.5.53 tag was cut. v1.5.54 is the release that actually ships it.
|
|
409
|
+
|
|
410
|
+
### v1.5.53 Highlights
|
|
411
|
+
|
|
412
|
+
- **Fix: switching a session's model now sticks in the UI** ([#50](https://github.com/swarmclawai/swarmclaw/pull/50), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). The **Switch Model** panel in the agent inspector was reading from `agent.provider` / `agent.model` (the agent's defaults) instead of `session.provider` / `session.model`, so after saving a model switch the collapsed pill still showed the agent default, the combobox reset to the default when reopened, and `selectedProvider` reverted on every save. `ModelSwitcherInline` now uses `session.provider || agent.provider` and `session.model || agent.model` as the source of truth, and its `useEffect` syncs to `session.provider` changes so a successful save updates the panel immediately.
|
|
413
|
+
|
|
402
414
|
### v1.5.52 Highlights
|
|
403
415
|
|
|
404
416
|
- **Session X-Ray now surfaces the backend execution log** ([#48](https://github.com/swarmclawai/swarmclaw/pull/48), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). The debug panel fetches entries from the SQLite execution log on open and merges them with in-memory message events, sorted by time. Expandable entries show provider, model, stream errors, duration, and token counts — the info that was previously invisible when Ollama or other local-model runs failed silently. A new **Tools** filter tab, an `exec` badge for log-sourced entries, an entry count in the stats bar, and a Refresh button round it out. New API route `GET /api/chats/:id/execution-log` with `limit`, `since`, and `category` query params, registered in the CLI manifest as `swarmclaw chats execution-log`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.54",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
4
|
+
import { formatZodError } from '@/lib/validation/schemas'
|
|
5
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
6
|
+
import { createMissionFromTemplate } from '@/lib/server/missions/mission-service'
|
|
7
|
+
import { patchSession } from '@/lib/server/sessions/session-repository'
|
|
8
|
+
|
|
9
|
+
export const dynamic = 'force-dynamic'
|
|
10
|
+
|
|
11
|
+
const BudgetOverrideSchema = z.object({
|
|
12
|
+
maxUsd: z.number().positive().nullable().optional(),
|
|
13
|
+
maxTokens: z.number().positive().int().nullable().optional(),
|
|
14
|
+
maxToolCalls: z.number().positive().int().nullable().optional(),
|
|
15
|
+
maxWallclockSec: z.number().positive().int().nullable().optional(),
|
|
16
|
+
maxTurns: z.number().positive().int().nullable().optional(),
|
|
17
|
+
warnAtFractions: z.array(z.number().positive().lt(1)).max(10).optional(),
|
|
18
|
+
}).partial()
|
|
19
|
+
|
|
20
|
+
const ReportScheduleSchema = z.object({
|
|
21
|
+
intervalSec: z.number().int().min(30),
|
|
22
|
+
format: z.enum(['markdown', 'slack', 'discord', 'email', 'audio']),
|
|
23
|
+
enabled: z.boolean().default(true),
|
|
24
|
+
lastReportAt: z.number().nullable().optional(),
|
|
25
|
+
}).strict()
|
|
26
|
+
|
|
27
|
+
const InstantiateSchema = z.object({
|
|
28
|
+
rootSessionId: z.string().min(1, 'rootSessionId is required'),
|
|
29
|
+
overrides: z.object({
|
|
30
|
+
title: z.string().min(1).max(200).optional(),
|
|
31
|
+
goal: z.string().min(1).max(4000).optional(),
|
|
32
|
+
successCriteria: z.array(z.string().min(1)).max(32).optional(),
|
|
33
|
+
budget: BudgetOverrideSchema.optional(),
|
|
34
|
+
reportSchedule: ReportScheduleSchema.nullable().optional(),
|
|
35
|
+
agentIds: z.array(z.string().min(1)).max(32).optional(),
|
|
36
|
+
reportConnectorIds: z.array(z.string().min(1)).max(8).optional(),
|
|
37
|
+
}).optional(),
|
|
38
|
+
}).strict()
|
|
39
|
+
|
|
40
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
41
|
+
const { id } = await params
|
|
42
|
+
const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
|
|
43
|
+
if (error) return error
|
|
44
|
+
const parsed = InstantiateSchema.safeParse(body)
|
|
45
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
46
|
+
|
|
47
|
+
const result = createMissionFromTemplate({
|
|
48
|
+
templateId: id,
|
|
49
|
+
rootSessionId: parsed.data.rootSessionId,
|
|
50
|
+
overrides: parsed.data.overrides,
|
|
51
|
+
})
|
|
52
|
+
if (!result) return notFound()
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
patchSession(result.mission.rootSessionId, (current) => {
|
|
56
|
+
if (!current) return null
|
|
57
|
+
return { ...current, missionId: result.mission.id }
|
|
58
|
+
})
|
|
59
|
+
} catch {
|
|
60
|
+
// Session may not exist yet; budget hook falls back to service map.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return NextResponse.json({ mission: result.mission, template: result.template })
|
|
64
|
+
}
|
|
@@ -5,7 +5,12 @@ import { api } from '@/lib/app/api-client'
|
|
|
5
5
|
import { MainContent } from '@/components/layout/main-content'
|
|
6
6
|
import { HintTip } from '@/components/shared/hint-tip'
|
|
7
7
|
import { inputClass } from '@/components/shared/form-styles'
|
|
8
|
-
import
|
|
8
|
+
import { MissionTemplateGallery } from '@/components/missions/mission-template-gallery'
|
|
9
|
+
import {
|
|
10
|
+
MissionTemplateInstallDialog,
|
|
11
|
+
type InstantiateInput,
|
|
12
|
+
} from '@/components/missions/mission-template-install-dialog'
|
|
13
|
+
import type { Mission, MissionReport, MissionEvent, MissionTemplate, Session } from '@/types'
|
|
9
14
|
import { toast } from 'sonner'
|
|
10
15
|
|
|
11
16
|
const POLL_MS = 4_000
|
|
@@ -489,6 +494,9 @@ export default function MissionsPage() {
|
|
|
489
494
|
const [createOpen, setCreateOpen] = useState(false)
|
|
490
495
|
const [busy, setBusy] = useState(false)
|
|
491
496
|
const [loaded, setLoaded] = useState(false)
|
|
497
|
+
const [templates, setTemplates] = useState<MissionTemplate[]>([])
|
|
498
|
+
const [galleryOpen, setGalleryOpen] = useState(false)
|
|
499
|
+
const [installTemplate, setInstallTemplate] = useState<MissionTemplate | null>(null)
|
|
492
500
|
|
|
493
501
|
const selected = useMemo(() => missions.find((m) => m.id === selectedId) ?? null, [missions, selectedId])
|
|
494
502
|
|
|
@@ -534,7 +542,15 @@ export default function MissionsPage() {
|
|
|
534
542
|
api<Session[]>('GET', '/chats').then((s) => {
|
|
535
543
|
setSessions(Array.isArray(s) ? s : Object.values(s))
|
|
536
544
|
}).catch(() => setSessions([]))
|
|
537
|
-
}, [createOpen])
|
|
545
|
+
}, [createOpen, galleryOpen, installTemplate])
|
|
546
|
+
|
|
547
|
+
useEffect(() => {
|
|
548
|
+
let cancelled = false
|
|
549
|
+
api<MissionTemplate[]>('GET', '/missions/templates')
|
|
550
|
+
.then((list) => { if (!cancelled) setTemplates(Array.isArray(list) ? list : []) })
|
|
551
|
+
.catch(() => { if (!cancelled) setTemplates([]) })
|
|
552
|
+
return () => { cancelled = true }
|
|
553
|
+
}, [])
|
|
538
554
|
|
|
539
555
|
const handleAction = useCallback(async (action: string, reason?: string) => {
|
|
540
556
|
if (!selectedId) return
|
|
@@ -571,28 +587,60 @@ export default function MissionsPage() {
|
|
|
571
587
|
toast.success(`Mission "${created.title}" created`)
|
|
572
588
|
}, [refreshList])
|
|
573
589
|
|
|
590
|
+
const handleInstallTemplate = useCallback(async (template: MissionTemplate, input: InstantiateInput) => {
|
|
591
|
+
const result = await api<{ mission: Mission }>(
|
|
592
|
+
'POST',
|
|
593
|
+
`/missions/templates/${encodeURIComponent(template.id)}/instantiate`,
|
|
594
|
+
input,
|
|
595
|
+
)
|
|
596
|
+
await refreshList()
|
|
597
|
+
setSelectedId(result.mission.id)
|
|
598
|
+
setGalleryOpen(false)
|
|
599
|
+
toast.success(`Mission "${result.mission.title}" installed`)
|
|
600
|
+
}, [refreshList])
|
|
601
|
+
|
|
574
602
|
return (
|
|
575
603
|
<MainContent>
|
|
576
604
|
<div className="flex-1 flex min-h-0">
|
|
577
605
|
<div className="w-[340px] shrink-0 border-r border-white/[0.06] flex flex-col min-h-0">
|
|
578
|
-
<div className="p-3 border-b border-white/[0.06]
|
|
579
|
-
<div>
|
|
580
|
-
<div
|
|
581
|
-
|
|
606
|
+
<div className="p-3 border-b border-white/[0.06]">
|
|
607
|
+
<div className="flex items-center justify-between mb-2">
|
|
608
|
+
<div>
|
|
609
|
+
<div className="text-[13px] font-600">Missions</div>
|
|
610
|
+
<div className="text-[10px] text-text-3">Autonomous goal-driven runs</div>
|
|
611
|
+
</div>
|
|
612
|
+
<button
|
|
613
|
+
onClick={() => setCreateOpen(true)}
|
|
614
|
+
className="text-[11px] font-600 px-2.5 py-1 rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15"
|
|
615
|
+
>
|
|
616
|
+
+ New
|
|
617
|
+
</button>
|
|
582
618
|
</div>
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
619
|
+
{templates.length > 0 && (
|
|
620
|
+
<button
|
|
621
|
+
onClick={() => setGalleryOpen(true)}
|
|
622
|
+
className="w-full text-left text-[11px] font-600 px-2.5 py-1.5 rounded border border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text"
|
|
623
|
+
>
|
|
624
|
+
Browse {templates.length} starter templates →
|
|
625
|
+
</button>
|
|
626
|
+
)}
|
|
589
627
|
</div>
|
|
590
628
|
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1.5">
|
|
591
629
|
{!loaded ? (
|
|
592
630
|
<div className="text-[11px] text-text-3 p-3">Loading...</div>
|
|
593
631
|
) : missions.length === 0 ? (
|
|
594
|
-
<div className="
|
|
595
|
-
|
|
632
|
+
<div className="flex flex-col gap-2 p-3">
|
|
633
|
+
<div className="text-[11px] text-text-3">
|
|
634
|
+
No missions yet. Start from a template or create one from scratch.
|
|
635
|
+
</div>
|
|
636
|
+
{templates.length > 0 && (
|
|
637
|
+
<button
|
|
638
|
+
onClick={() => setGalleryOpen(true)}
|
|
639
|
+
className="text-[11px] font-600 px-2.5 py-1 rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15 self-start"
|
|
640
|
+
>
|
|
641
|
+
Open template gallery
|
|
642
|
+
</button>
|
|
643
|
+
)}
|
|
596
644
|
</div>
|
|
597
645
|
) : (
|
|
598
646
|
missions.map((m) => (
|
|
@@ -616,6 +664,13 @@ export default function MissionsPage() {
|
|
|
616
664
|
onAction={handleAction}
|
|
617
665
|
onForceReport={handleForceReport}
|
|
618
666
|
/>
|
|
667
|
+
) : loaded && missions.length === 0 && templates.length > 0 ? (
|
|
668
|
+
<div className="p-6">
|
|
669
|
+
<MissionTemplateGallery
|
|
670
|
+
templates={templates}
|
|
671
|
+
onInstall={(t) => setInstallTemplate(t)}
|
|
672
|
+
/>
|
|
673
|
+
</div>
|
|
619
674
|
) : (
|
|
620
675
|
<div className="flex items-center justify-center h-full text-text-3 text-[12px]">
|
|
621
676
|
{loaded && missions.length === 0 ? 'Create a mission to get started.' : 'Select a mission'}
|
|
@@ -630,6 +685,34 @@ export default function MissionsPage() {
|
|
|
630
685
|
onClose={() => setCreateOpen(false)}
|
|
631
686
|
onCreate={handleCreate}
|
|
632
687
|
/>
|
|
688
|
+
|
|
689
|
+
{galleryOpen && (
|
|
690
|
+
<div
|
|
691
|
+
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
|
692
|
+
onClick={() => setGalleryOpen(false)}
|
|
693
|
+
>
|
|
694
|
+
<div
|
|
695
|
+
className="w-full max-w-4xl max-h-[90vh] overflow-y-auto rounded-[14px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-6"
|
|
696
|
+
onClick={(e) => e.stopPropagation()}
|
|
697
|
+
>
|
|
698
|
+
<div className="flex items-center justify-between mb-4">
|
|
699
|
+
<div className="text-[15px] font-700 text-text">Mission templates</div>
|
|
700
|
+
<button onClick={() => setGalleryOpen(false)} className="text-text-3 text-[12px] hover:text-text">Close</button>
|
|
701
|
+
</div>
|
|
702
|
+
<MissionTemplateGallery
|
|
703
|
+
templates={templates}
|
|
704
|
+
onInstall={(t) => setInstallTemplate(t)}
|
|
705
|
+
/>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
)}
|
|
709
|
+
|
|
710
|
+
<MissionTemplateInstallDialog
|
|
711
|
+
template={installTemplate}
|
|
712
|
+
sessions={sessions}
|
|
713
|
+
onClose={() => setInstallTemplate(null)}
|
|
714
|
+
onInstall={handleInstallTemplate}
|
|
715
|
+
/>
|
|
633
716
|
</MainContent>
|
|
634
717
|
)
|
|
635
718
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -817,6 +817,8 @@ const COMMAND_GROUPS = [
|
|
|
817
817
|
cmd('reports', 'GET', '/missions/:id/reports', 'List mission reports'),
|
|
818
818
|
cmd('report-now', 'POST', '/missions/:id/reports', 'Force-generate a mission report now'),
|
|
819
819
|
cmd('events', 'GET', '/missions/:id/events', 'List mission events (use --query sinceAt=..., --query untilAt=...)'),
|
|
820
|
+
cmd('templates', 'GET', '/missions/templates', 'List built-in mission templates'),
|
|
821
|
+
cmd('instantiate', 'POST', '/missions/templates/:id/instantiate', 'Create a mission from a template', { expectsJsonBody: true }),
|
|
820
822
|
],
|
|
821
823
|
},
|
|
822
824
|
{
|
package/src/cli/spec.js
CHANGED
|
@@ -571,6 +571,8 @@ const COMMAND_GROUPS = {
|
|
|
571
571
|
reports: { description: 'List mission reports', method: 'GET', path: '/missions/:id/reports', params: ['id'] },
|
|
572
572
|
'report-now': { description: 'Force-generate a mission report now', method: 'POST', path: '/missions/:id/reports', params: ['id'] },
|
|
573
573
|
events: { description: 'List mission events', method: 'GET', path: '/missions/:id/events', params: ['id'] },
|
|
574
|
+
templates: { description: 'List built-in mission templates', method: 'GET', path: '/missions/templates' },
|
|
575
|
+
instantiate: { description: 'Create a mission from a template', method: 'POST', path: '/missions/templates/:id/instantiate', params: ['id'], body: true },
|
|
574
576
|
},
|
|
575
577
|
},
|
|
576
578
|
}
|
|
@@ -73,22 +73,24 @@ function ModelSwitcherInline({ session, agent }: { session: Session; agent: Agen
|
|
|
73
73
|
const refreshSession = useAppStore((s) => s.refreshSession)
|
|
74
74
|
const streaming = useChatStore((s) => s.streaming)
|
|
75
75
|
const [expanded, setExpanded] = useState(false)
|
|
76
|
-
const [selectedProvider, setSelectedProvider] = useState(agent.provider)
|
|
76
|
+
const [selectedProvider, setSelectedProvider] = useState(session.provider || agent.provider)
|
|
77
77
|
const [saving, setSaving] = useState(false)
|
|
78
78
|
|
|
79
79
|
useEffect(() => {
|
|
80
80
|
void loadProviders()
|
|
81
81
|
void loadProviderConfigs()
|
|
82
82
|
}, [loadProviderConfigs, loadProviders])
|
|
83
|
-
|
|
83
|
+
// Sync selectedProvider when the session's provider changes (e.g. after a successful save)
|
|
84
|
+
useEffect(() => { setSelectedProvider(session.provider || agent.provider) }, [session.provider, agent.provider])
|
|
84
85
|
|
|
85
86
|
const agentSelectableProviders = useMemo(
|
|
86
87
|
() => buildAgentSelectableProviders(providers, providerConfigs),
|
|
87
88
|
[providerConfigs, providers],
|
|
88
89
|
)
|
|
89
90
|
const currentProviderInfo = agentSelectableProviders.find((p) => p.id === selectedProvider)
|
|
90
|
-
const
|
|
91
|
-
const
|
|
91
|
+
const activeSessionProvider = agentSelectableProviders.find((p) => p.id === (session.provider || agent.provider))
|
|
92
|
+
const effectiveProvider = session.provider || agent.provider
|
|
93
|
+
const providerLabel = PROVIDER_LABELS[effectiveProvider] || activeSessionProvider?.name || effectiveProvider.replace(/-/g, ' ')
|
|
92
94
|
|
|
93
95
|
const handleModelChange = async (model: string) => {
|
|
94
96
|
if (saving) return
|
|
@@ -117,7 +119,7 @@ function ModelSwitcherInline({ session, agent }: { session: Session; agent: Agen
|
|
|
117
119
|
{providerLabel}
|
|
118
120
|
</span>
|
|
119
121
|
<span className="inline-flex max-w-[180px] items-center rounded-[8px] border border-white/[0.06] bg-white/[0.03] px-2 py-1 text-[10px] font-mono text-text-3/70 truncate group-hover:border-white/[0.1] group-hover:text-text-2 transition-colors">
|
|
120
|
-
{agent.model || 'Default model'}
|
|
122
|
+
{session.model || agent.model || 'Default model'}
|
|
121
123
|
</span>
|
|
122
124
|
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/30 group-hover:text-text-3/60 transition-colors ml-auto shrink-0">
|
|
123
125
|
<polyline points="6 9 12 15 18 9" />
|
|
@@ -157,7 +159,7 @@ function ModelSwitcherInline({ session, agent }: { session: Session; agent: Agen
|
|
|
157
159
|
{currentProviderInfo && (
|
|
158
160
|
<ModelCombobox
|
|
159
161
|
providerId={currentProviderInfo.id}
|
|
160
|
-
value={agent.model || currentProviderInfo.models[0] || ''}
|
|
162
|
+
value={session.model || agent.model || currentProviderInfo.models[0] || ''}
|
|
161
163
|
onChange={(m) => void handleModelChange(m)}
|
|
162
164
|
models={currentProviderInfo.models}
|
|
163
165
|
defaultModels={currentProviderInfo.defaultModels}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import type { MissionTemplate, MissionTemplateCategory } from '@/types'
|
|
5
|
+
|
|
6
|
+
const CATEGORY_LABELS: Record<MissionTemplateCategory, string> = {
|
|
7
|
+
research: 'Research',
|
|
8
|
+
communication: 'Communication',
|
|
9
|
+
monitoring: 'Monitoring',
|
|
10
|
+
productivity: 'Productivity',
|
|
11
|
+
support: 'Support',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
templates: MissionTemplate[]
|
|
16
|
+
onInstall: (template: MissionTemplate) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function MissionTemplateGallery({ templates, onInstall }: Props) {
|
|
20
|
+
const [category, setCategory] = useState<'all' | MissionTemplateCategory>('all')
|
|
21
|
+
|
|
22
|
+
const categories = useMemo(() => {
|
|
23
|
+
const seen = new Set<MissionTemplateCategory>()
|
|
24
|
+
for (const template of templates) seen.add(template.category)
|
|
25
|
+
return Array.from(seen)
|
|
26
|
+
}, [templates])
|
|
27
|
+
|
|
28
|
+
const filtered = useMemo(() => {
|
|
29
|
+
if (category === 'all') return templates
|
|
30
|
+
return templates.filter((t) => t.category === category)
|
|
31
|
+
}, [templates, category])
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex flex-col gap-4">
|
|
35
|
+
<div>
|
|
36
|
+
<h2 className="text-[15px] font-700 text-text">Start from a template</h2>
|
|
37
|
+
<p className="text-[12px] text-text-3 mt-1">
|
|
38
|
+
Pre-wired goals, budgets, and report schedules. Tweak anything before you install.
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div className="flex flex-wrap gap-1.5">
|
|
43
|
+
<FilterChip active={category === 'all'} onClick={() => setCategory('all')} label="All" />
|
|
44
|
+
{categories.map((c) => (
|
|
45
|
+
<FilterChip
|
|
46
|
+
key={c}
|
|
47
|
+
active={category === c}
|
|
48
|
+
onClick={() => setCategory(c)}
|
|
49
|
+
label={CATEGORY_LABELS[c]}
|
|
50
|
+
/>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
55
|
+
{filtered.map((template) => (
|
|
56
|
+
<TemplateCard key={template.id} template={template} onInstall={() => onInstall(template)} />
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function FilterChip({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
|
|
64
|
+
return (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={onClick}
|
|
68
|
+
className={`text-[11px] font-600 px-2.5 py-1 rounded-full border transition-colors ${
|
|
69
|
+
active
|
|
70
|
+
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-200'
|
|
71
|
+
: 'border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text'
|
|
72
|
+
}`}
|
|
73
|
+
>
|
|
74
|
+
{label}
|
|
75
|
+
</button>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function TemplateCard({ template, onInstall }: { template: MissionTemplate; onInstall: () => void }) {
|
|
80
|
+
return (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={onInstall}
|
|
84
|
+
className="text-left group flex flex-col gap-3 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4 transition-all hover:border-white/[0.16] hover:bg-white/[0.04]"
|
|
85
|
+
>
|
|
86
|
+
<div className="flex items-start gap-3">
|
|
87
|
+
<span className="text-[22px] leading-none" aria-hidden>{template.icon}</span>
|
|
88
|
+
<div className="flex-1 min-w-0">
|
|
89
|
+
<div className="text-[13px] font-700 text-text">{template.name}</div>
|
|
90
|
+
<div className="text-[10px] uppercase tracking-wide text-text-3/70 mt-0.5">
|
|
91
|
+
{CATEGORY_LABELS[template.category]}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="text-[12px] text-text-3 leading-[1.55] line-clamp-3">{template.description}</div>
|
|
96
|
+
<div className="flex items-center justify-between mt-auto">
|
|
97
|
+
<div className="flex flex-wrap gap-1">
|
|
98
|
+
{template.tags.slice(0, 3).map((tag) => (
|
|
99
|
+
<span
|
|
100
|
+
key={tag}
|
|
101
|
+
className="text-[10px] text-text-3/70 px-1.5 py-0.5 rounded border border-white/[0.06] bg-white/[0.02]"
|
|
102
|
+
>
|
|
103
|
+
{tag}
|
|
104
|
+
</span>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
<span className="text-[11px] font-600 text-emerald-300 group-hover:text-emerald-200">
|
|
108
|
+
Install →
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
</button>
|
|
112
|
+
)
|
|
113
|
+
}
|