Merge branch 'feature/pull-project' of git.digitaltelepresence.com:rob/gadget into feature/pull-project
This commit is contained in:
commit
5058489c2f
@ -222,6 +222,25 @@ export interface AuthResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ProjectSkill {
|
||||
_id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
modes: string[];
|
||||
}
|
||||
|
||||
export interface ProjectTask {
|
||||
_id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
selectedModel: string;
|
||||
mode: string;
|
||||
crontab: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
lastRun?: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
_id: string;
|
||||
createdAt: string;
|
||||
@ -230,6 +249,10 @@ export interface Project {
|
||||
name: string;
|
||||
slug: string;
|
||||
gitUrl?: string;
|
||||
description?: string;
|
||||
system?: string;
|
||||
skills: ProjectSkill[];
|
||||
tasks: ProjectTask[];
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
@ -255,6 +278,10 @@ export const projectApi = {
|
||||
slug: string;
|
||||
gitUrl: string;
|
||||
status: string;
|
||||
description: string;
|
||||
system: string;
|
||||
skills: ProjectSkill[];
|
||||
tasks: ProjectTask[];
|
||||
}>,
|
||||
) => api.put<Project>(`/api/v1/projects/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import slug from "slug";
|
||||
import type { User, Project } from "../lib/api";
|
||||
import type { User, Project, ProjectSkill, ProjectTask } from "../lib/api";
|
||||
import {
|
||||
projectApi,
|
||||
droneApi,
|
||||
@ -195,21 +195,118 @@ function PullProjectForm({
|
||||
|
||||
interface EditProjectFormProps {
|
||||
project: Project;
|
||||
providers: AiProvider[];
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ value: "plan", label: "Plan", color: "bg-blue-900/50 text-blue-300" },
|
||||
{ value: "build", label: "Build", color: "bg-green-900/50 text-green-300" },
|
||||
{ value: "test", label: "Test", color: "bg-yellow-900/50 text-yellow-300" },
|
||||
{ value: "ship", label: "Ship", color: "bg-purple-900/50 text-purple-300" },
|
||||
{ value: "dev", label: "Dev", color: "bg-red-900/50 text-red-300" },
|
||||
];
|
||||
|
||||
function EditProjectForm({
|
||||
project,
|
||||
providers,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: EditProjectFormProps) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [slugValue, setSlugValue] = useState(project.slug);
|
||||
const [gitUrl, setGitUrl] = useState(project.gitUrl || "");
|
||||
const [description, setDescription] = useState(project.description || "");
|
||||
const [system, setSystem] = useState(project.system || "");
|
||||
const [skills, setSkills] = useState<ProjectSkill[]>(project.skills || []);
|
||||
const [tasks, setTasks] = useState<ProjectTask[]>(project.tasks || []);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Track which skills/tasks are expanded
|
||||
const [expandedSkills, setExpandedSkills] = useState<Set<string>>(new Set());
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSkillExpanded = (id: string) => {
|
||||
setExpandedSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTaskExpanded = (id: string) => {
|
||||
setExpandedTasks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addSkill = () => {
|
||||
const newSkill: ProjectSkill = {
|
||||
_id: crypto.randomUUID(),
|
||||
name: "",
|
||||
content: "",
|
||||
modes: [],
|
||||
};
|
||||
setSkills([...skills, newSkill]);
|
||||
setExpandedSkills((prev) => new Set(prev).add(newSkill._id));
|
||||
};
|
||||
|
||||
const removeSkill = (id: string) => {
|
||||
setSkills(skills.filter((s) => s._id !== id));
|
||||
setExpandedSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateSkill = (id: string, updates: Partial<ProjectSkill>) => {
|
||||
setSkills(skills.map((s) => (s._id === id ? { ...s, ...updates } : s)));
|
||||
};
|
||||
|
||||
const toggleSkillMode = (id: string, mode: string) => {
|
||||
const skill = skills.find((s) => s._id === id);
|
||||
if (!skill) return;
|
||||
const modes = skill.modes.includes(mode)
|
||||
? skill.modes.filter((m) => m !== mode)
|
||||
: [...skill.modes, mode];
|
||||
updateSkill(id, { modes });
|
||||
};
|
||||
|
||||
const addTask = () => {
|
||||
const newTask: ProjectTask = {
|
||||
_id: crypto.randomUUID(),
|
||||
name: "",
|
||||
provider: providers[0]?._id || "",
|
||||
selectedModel: "",
|
||||
mode: "build",
|
||||
crontab: "0 0 * * * *",
|
||||
content: "",
|
||||
enabled: true,
|
||||
};
|
||||
setTasks([...tasks, newTask]);
|
||||
setExpandedTasks((prev) => new Set(prev).add(newTask._id));
|
||||
};
|
||||
|
||||
const removeTask = (id: string) => {
|
||||
setTasks(tasks.filter((t) => t._id !== id));
|
||||
setExpandedTasks((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateTask = (id: string, updates: Partial<ProjectTask>) => {
|
||||
setTasks(tasks.map((t) => (t._id === id ? { ...t, ...updates } : t)));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !slugValue) {
|
||||
@ -225,6 +322,10 @@ function EditProjectForm({
|
||||
name,
|
||||
slug: slug(slugValue),
|
||||
gitUrl: gitUrl || undefined,
|
||||
description: description || undefined,
|
||||
system: system || undefined,
|
||||
skills: skills.filter((s) => s.name.trim()), // only send skills with names
|
||||
tasks: tasks.filter((t) => t.name.trim()), // only send tasks with names
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
@ -234,10 +335,14 @@ function EditProjectForm({
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none";
|
||||
|
||||
return (
|
||||
<div className="max-w-lg">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-xl font-semibold mb-6">Edit Project</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Basic Fields */}
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
Project Name *
|
||||
@ -246,12 +351,9 @@ function EditProjectForm({
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
||||
className={inputClass}
|
||||
placeholder="My Project"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Used for display purposes only
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
@ -261,7 +363,7 @@ function EditProjectForm({
|
||||
type="text"
|
||||
value={slugValue}
|
||||
onChange={(e) => setSlugValue(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
||||
className={inputClass}
|
||||
placeholder="my-project"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
@ -277,10 +379,431 @@ function EditProjectForm({
|
||||
type="text"
|
||||
value={gitUrl}
|
||||
onChange={(e) => setGitUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
||||
className={inputClass}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className={`${inputClass} resize-y`}
|
||||
rows={3}
|
||||
placeholder="Brief description of the project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
System Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={system}
|
||||
onChange={(e) => setSystem(e.target.value)}
|
||||
className={`${inputClass} resize-y font-mono text-sm`}
|
||||
rows={6}
|
||||
placeholder="Project-specific instructions appended to the agent's system prompt..."
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Appended to the agent's system prompt when working on this project.
|
||||
Use this to provide project-specific instructions, such as how to
|
||||
manage API servers as subprocesses during development.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skills Editor */}
|
||||
<div className="border border-border-default rounded">
|
||||
<div className="flex items-center justify-between p-3 border-b border-border-subtle">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Skills ({skills.length})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSkill}
|
||||
className="px-3 py-1 text-xs border border-border-default text-text-secondary rounded hover:bg-bg-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
+ Add Skill
|
||||
</button>
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<div className="p-3 text-sm text-text-muted">
|
||||
No skills defined. Skills provide project-specific knowledge that
|
||||
agents can discover using the <code className="text-text-secondary">list_skills()</code> and{" "}
|
||||
<code className="text-text-secondary">read_skill()</code> tools.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{skills.map((skill) => {
|
||||
const isExpanded = expandedSkills.has(skill._id);
|
||||
return (
|
||||
<div key={skill._id}>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-bg-tertiary/50 transition-colors"
|
||||
onClick={() => toggleSkillExpanded(skill._id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-text-muted text-xs">
|
||||
{isExpanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span className="text-sm text-text-primary truncate">
|
||||
{skill.name || "Unnamed Skill"}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{skill.modes.map((mode) => {
|
||||
const opt = MODE_OPTIONS.find(
|
||||
(m) => m.value === mode,
|
||||
);
|
||||
return opt ? (
|
||||
<span
|
||||
key={mode}
|
||||
className={`px-1.5 py-0.5 text-[10px] rounded ${opt.color}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSkill(skill._id);
|
||||
}}
|
||||
className="p-1 text-text-muted hover:text-red-400 transition-colors"
|
||||
title="Remove skill"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="p-3 pt-0 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={skill.name}
|
||||
onChange={(e) =>
|
||||
updateSkill(skill._id, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
className={inputClass}
|
||||
placeholder="Skill name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
value={skill.content}
|
||||
onChange={(e) =>
|
||||
updateSkill(skill._id, {
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
className={`${inputClass} resize-y font-mono text-sm`}
|
||||
rows={6}
|
||||
placeholder="Skill instructions and knowledge..."
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
What the agent reads via the{" "}
|
||||
<code>read_skill()</code> tool
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
Available Modes
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MODE_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skill.modes.includes(opt.value)}
|
||||
onChange={() =>
|
||||
toggleSkillMode(skill._id, opt.value)
|
||||
}
|
||||
className="rounded border-border-default bg-bg-tertiary"
|
||||
/>
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-xs rounded ${opt.color}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Which modes can see this skill via{" "}
|
||||
<code>list_skills()</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tasks Editor */}
|
||||
<div className="border border-border-default rounded">
|
||||
<div className="flex items-center justify-between p-3 border-b border-border-subtle">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Tasks ({tasks.length})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTask}
|
||||
className="px-3 py-1 text-xs border border-border-default text-text-secondary rounded hover:bg-bg-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
+ Add Task
|
||||
</button>
|
||||
</div>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="p-3 text-sm text-text-muted">
|
||||
No tasks defined. Tasks are scheduled agent runs with a crontab
|
||||
schedule.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{tasks.map((task) => {
|
||||
const isExpanded = expandedTasks.has(task._id);
|
||||
const taskProvider = providers.find(
|
||||
(p) => p._id === task.provider,
|
||||
);
|
||||
return (
|
||||
<div key={task._id}>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-bg-tertiary/50 transition-colors"
|
||||
onClick={() => toggleTaskExpanded(task._id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-text-muted text-xs">
|
||||
{isExpanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span className="text-sm text-text-primary truncate">
|
||||
{task.name || "Unnamed Task"}
|
||||
</span>
|
||||
{task.enabled ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-green-900/50 text-green-300">
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-900/50 text-gray-400">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
{task.crontab && (
|
||||
<span className="font-mono text-[10px] text-text-muted">
|
||||
{task.crontab}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTask(task._id);
|
||||
}}
|
||||
className="p-1 text-text-muted hover:text-red-400 transition-colors"
|
||||
title="Remove task"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="p-3 pt-0 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={task.name}
|
||||
onChange={(e) =>
|
||||
updateTask(task._id, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
className={inputClass}
|
||||
placeholder="Task name"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
value={task.provider}
|
||||
onChange={(e) => {
|
||||
updateTask(task._id, {
|
||||
provider: e.target.value,
|
||||
selectedModel: "",
|
||||
});
|
||||
}}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Select provider</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p._id} value={p._id}>
|
||||
{p.name} ({p.apiType})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
value={task.selectedModel}
|
||||
onChange={(e) =>
|
||||
updateTask(task._id, {
|
||||
selectedModel: e.target.value,
|
||||
})
|
||||
}
|
||||
className={inputClass}
|
||||
disabled={!taskProvider}
|
||||
>
|
||||
<option value="">Select model</option>
|
||||
{taskProvider?.models
|
||||
?.sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
{model.parameterLabel
|
||||
? ` (${model.parameterLabel})`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
Mode
|
||||
</label>
|
||||
<select
|
||||
value={task.mode}
|
||||
onChange={(e) =>
|
||||
updateTask(task._id, {
|
||||
mode: e.target.value,
|
||||
})
|
||||
}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="plan">Plan</option>
|
||||
<option value="build">Build</option>
|
||||
<option value="test">Test</option>
|
||||
<option value="ship">Ship</option>
|
||||
<option value="dev">Dev</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
Schedule
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={task.crontab}
|
||||
onChange={(e) =>
|
||||
updateTask(task._id, {
|
||||
crontab: e.target.value,
|
||||
})
|
||||
}
|
||||
className={`${inputClass} font-mono text-sm`}
|
||||
placeholder="0 0 * * * *"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
6-field cron: sec min hour day month weekday
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={task.content}
|
||||
onChange={(e) =>
|
||||
updateTask(task._id, {
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
className={`${inputClass} resize-y font-mono text-sm`}
|
||||
rows={4}
|
||||
placeholder="Task prompt for the agent..."
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.enabled}
|
||||
onChange={(e) =>
|
||||
updateTask(task._id, {
|
||||
enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="rounded border-border-default bg-bg-tertiary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">
|
||||
Enabled
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
@ -305,12 +828,14 @@ function EditProjectForm({
|
||||
|
||||
interface ProjectInspectorProps {
|
||||
project: Project;
|
||||
providers: AiProvider[];
|
||||
onDelete: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
function ProjectInspector({
|
||||
project,
|
||||
providers,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: ProjectInspectorProps) {
|
||||
@ -336,6 +861,15 @@ function ProjectInspector({
|
||||
}
|
||||
};
|
||||
|
||||
const modeBadge = (mode: string) => {
|
||||
const opt = MODE_OPTIONS.find((m) => m.value === mode);
|
||||
return opt ? (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded ${opt.color}`}>
|
||||
{opt.label}
|
||||
</span>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-3xl">
|
||||
@ -343,6 +877,7 @@ function ProjectInspector({
|
||||
{editing ? (
|
||||
<EditProjectForm
|
||||
project={project}
|
||||
providers={providers}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSuccess={() => {
|
||||
setEditing(false);
|
||||
@ -351,7 +886,7 @@ function ProjectInspector({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-1">Name</div>
|
||||
@ -367,6 +902,16 @@ function ProjectInspector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-1">Description</div>
|
||||
<div className="text-text-primary text-sm">
|
||||
{project.description || (
|
||||
<span className="text-text-muted">Not configured</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-1">Git URL</div>
|
||||
<div className="text-text-primary font-mono text-sm break-all">
|
||||
@ -376,6 +921,75 @@ function ProjectInspector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-1">System Prompt</div>
|
||||
<div className="text-text-primary text-sm font-mono">
|
||||
{project.system ? (
|
||||
project.system.length > 200 ? (
|
||||
<span>{project.system.slice(0, 200)}...</span>
|
||||
) : (
|
||||
project.system
|
||||
)
|
||||
) : (
|
||||
<span className="text-text-muted font-sans">Not configured</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-2">Skills ({project.skills?.length || 0})</div>
|
||||
{project.skills && project.skills.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{project.skills.map((skill) => (
|
||||
<div
|
||||
key={skill._id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span className="text-text-primary">{skill.name}</span>
|
||||
<div className="flex gap-1">
|
||||
{skill.modes.map((mode) => modeBadge(mode))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-muted text-sm">No skills defined</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-2">Tasks ({project.tasks?.length || 0})</div>
|
||||
{project.tasks && project.tasks.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{project.tasks.map((task) => (
|
||||
<div
|
||||
key={task._id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span className="text-text-primary">{task.name}</span>
|
||||
{task.enabled ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-green-900/50 text-green-300">
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-900/50 text-gray-400">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-[10px] text-text-muted">
|
||||
{task.crontab}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-muted text-sm">No tasks defined</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-1">Status</div>
|
||||
@ -895,12 +1509,14 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(
|
||||
null,
|
||||
);
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showNewForm, setShowNewForm] = useState(false);
|
||||
const [showPullForm, setShowPullForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
loadProviders();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -923,6 +1539,15 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
const data = await providerApi.getAll();
|
||||
setProviders(data.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} catch (err) {
|
||||
console.error("Failed to load providers", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectProject = (project: Project) => {
|
||||
navigate(`/projects/${project.slug}`);
|
||||
};
|
||||
@ -1084,6 +1709,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||
{/* Center - Project Inspector */}
|
||||
<ProjectInspector
|
||||
project={selectedProject}
|
||||
providers={providers}
|
||||
onDelete={handleProjectDeleted}
|
||||
onUpdate={handleProjectUpdated}
|
||||
/>
|
||||
|
||||
@ -196,12 +196,16 @@ export class ProjectApiControllerV1 extends DtpController {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, slug, gitUrl, status } = req.body;
|
||||
const { name, slug, gitUrl, status, description, system, skills, tasks } = req.body;
|
||||
const updated = await projectService.update(project, {
|
||||
name,
|
||||
slug,
|
||||
gitUrl,
|
||||
status: status as ProjectStatus,
|
||||
description,
|
||||
system,
|
||||
skills,
|
||||
tasks,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
|
||||
@ -4,9 +4,33 @@
|
||||
|
||||
import { Schema, model } from "mongoose";
|
||||
|
||||
import { ProjectStatus, IProject } from "@gadget/api";
|
||||
import {
|
||||
ProjectStatus,
|
||||
IProject,
|
||||
IProjectSkill,
|
||||
ChatSessionMode,
|
||||
IProjectTask,
|
||||
Types,
|
||||
} from "@gadget/api";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const ProjectSkillSchema = new Schema<IProjectSkill>({
|
||||
name: { type: String, required: true },
|
||||
modes: { type: [String], enum: ChatSessionMode, required: true },
|
||||
content: { type: String, required: true },
|
||||
});
|
||||
|
||||
export const ProjectTaskSchema = new Schema<IProjectTask>({
|
||||
name: { type: String, required: true },
|
||||
provider: { type: Types.ObjectId, required: true, ref: "AiProvider" },
|
||||
selectedModel: { type: String, required: true },
|
||||
mode: { type: String, enum: ChatSessionMode, required: true },
|
||||
crontab: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
enabled: { type: Boolean, default: true, required: true },
|
||||
lastRun: { type: Types.ObjectId, ref: "ChatSession" },
|
||||
});
|
||||
|
||||
export const ProjectSchema = new Schema<IProject>({
|
||||
_id: { type: String, default: () => nanoid() },
|
||||
createdAt: { type: Date, default: Date.now, required: true },
|
||||
@ -20,6 +44,10 @@ export const ProjectSchema = new Schema<IProject>({
|
||||
required: true,
|
||||
},
|
||||
gitUrl: { type: String },
|
||||
description: { type: String },
|
||||
system: { type: String },
|
||||
skills: { type: [ProjectSkillSchema], default: [], required: true },
|
||||
tasks: { type: [ProjectTaskSchema], default: [], required: true },
|
||||
});
|
||||
|
||||
ProjectSchema.index(
|
||||
|
||||
@ -381,6 +381,15 @@ class ChatSessionService extends DtpService {
|
||||
.replace("{{session_block}}", sessionBlock)
|
||||
.replace("{{persona_block}}", personaBlock);
|
||||
|
||||
/*
|
||||
* Project System Prompt — appended as an appendix when the project
|
||||
* has a `system` field configured. This allows project-specific
|
||||
* instructions to be injected without modifying mode templates.
|
||||
*/
|
||||
if (project.system && project.system.trim().length > 0) {
|
||||
prompt += `\n\n## PROJECT CONFIGURATION\n\n### System Instructions\n${project.system.trim()}`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import slug from "slug";
|
||||
|
||||
import { MongooseBaseQueryOptions, PopulateOptions } from "mongoose";
|
||||
|
||||
import { IProject, IUser, ProjectStatus } from "@gadget/api";
|
||||
import { IProject, IProjectSkill, IProjectTask, IUser, ProjectStatus } from "@gadget/api";
|
||||
import Project from "@/models/project.js";
|
||||
|
||||
import { DtpService } from "../lib/service.js";
|
||||
@ -16,6 +16,10 @@ export interface IProjectDefinition {
|
||||
slug: string;
|
||||
gitUrl?: string;
|
||||
status?: ProjectStatus;
|
||||
description?: string;
|
||||
system?: string;
|
||||
skills?: IProjectSkill[];
|
||||
tasks?: IProjectTask[];
|
||||
}
|
||||
|
||||
class ProjectService extends DtpService {
|
||||
@ -158,10 +162,41 @@ class ProjectService extends DtpService {
|
||||
if (definition.gitUrl !== project.gitUrl) {
|
||||
update.$set.gitUrl = definition.gitUrl;
|
||||
}
|
||||
} else if (definition.gitUrl === undefined && definition.hasOwnProperty('gitUrl')) {
|
||||
// Only unset if explicitly provided as undefined/empty
|
||||
} else {
|
||||
update.$unset.gitUrl = 1;
|
||||
}
|
||||
|
||||
// Description — set or unset
|
||||
if (definition.description !== undefined) {
|
||||
if (definition.description !== project.description) {
|
||||
update.$set.description = definition.description;
|
||||
}
|
||||
}
|
||||
|
||||
// System prompt — set or unset
|
||||
if (definition.system !== undefined) {
|
||||
if (definition.system !== project.system) {
|
||||
update.$set.system = definition.system;
|
||||
}
|
||||
}
|
||||
|
||||
// Skills — full array replacement
|
||||
if (definition.skills !== undefined) {
|
||||
update.$set.skills = definition.skills;
|
||||
}
|
||||
|
||||
// Tasks — full array replacement
|
||||
if (definition.tasks !== undefined) {
|
||||
update.$set.tasks = definition.tasks;
|
||||
}
|
||||
|
||||
// Clean up empty $unset to avoid MongoDB errors
|
||||
if (Object.keys(update.$unset as Record<string, unknown>).length === 0) {
|
||||
delete (update as any).$unset;
|
||||
}
|
||||
|
||||
const newProject = await Project.findOneAndUpdate(
|
||||
{ _id: project._id },
|
||||
update,
|
||||
|
||||
@ -52,6 +52,8 @@ import {
|
||||
ShellExecTool,
|
||||
SubagentTool,
|
||||
SubprocessTool,
|
||||
ListSkillsTool,
|
||||
ReadSkillTool,
|
||||
type DroneToolboxEnvironment,
|
||||
} from "../tools/index.ts";
|
||||
|
||||
@ -131,6 +133,10 @@ class AgentService extends GadgetService {
|
||||
subagentTool.setSpawner((agentType, prompt) => this.spawnSubagent(agentType, prompt));
|
||||
this.toolbox.register(subagentTool, readOnlyModes);
|
||||
|
||||
// Project tools — skill discovery: available in all modes
|
||||
this.toolbox.register(new ListSkillsTool(this.toolbox), readOnlyModes);
|
||||
this.toolbox.register(new ReadSkillTool(this.toolbox), readOnlyModes);
|
||||
|
||||
this.log.info("started");
|
||||
}
|
||||
|
||||
@ -548,6 +554,8 @@ class AgentService extends GadgetService {
|
||||
projectDir: WorkspaceService.getProjectDirectory(project.slug),
|
||||
cacheDir,
|
||||
});
|
||||
|
||||
this.toolbox.updateProjectContext(project, turn.mode);
|
||||
}
|
||||
|
||||
async spawnSubagent(
|
||||
|
||||
@ -7,3 +7,4 @@ export * from "./chat/index.ts";
|
||||
export * from "./system/index.ts";
|
||||
export * from "./network/index.ts";
|
||||
export * from "./plan/index.ts";
|
||||
export * from "./project/index.ts";
|
||||
|
||||
5
gadget-drone/src/tools/project/index.ts
Normal file
5
gadget-drone/src/tools/project/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
export { ListSkillsTool } from "./list-skills.ts";
|
||||
export { ReadSkillTool } from "./read-skill.ts";
|
||||
62
gadget-drone/src/tools/project/list-skills.ts
Normal file
62
gadget-drone/src/tools/project/list-skills.ts
Normal file
@ -0,0 +1,62 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import type { IAiLogger, IToolArguments } from "@gadget/ai";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
|
||||
export class ListSkillsTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "list_skills";
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "project";
|
||||
}
|
||||
|
||||
public definition = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"List all skills defined on the current project that are available for the current mode. Skills contain project-specific knowledge and instructions that can help you work more effectively. Use read_skill(index) to read a skill's full content.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public async execute(_args: IToolArguments, _logger: IAiLogger): Promise<string> {
|
||||
const project = this.toolbox.env.project;
|
||||
if (!project) {
|
||||
return "No project is configured for this session.";
|
||||
}
|
||||
|
||||
const mode = this.toolbox.env.chatSessionMode;
|
||||
const skills = project.skills.filter(
|
||||
(s) => mode && s.modes.includes(mode),
|
||||
);
|
||||
|
||||
if (skills.length === 0) {
|
||||
return mode
|
||||
? `Project Skills (0 available in ${mode} mode)\n\nNo skills are defined for this project in ${mode} mode.`
|
||||
: "No skills are defined for this project.";
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`Project Skills (${skills.length} available in ${mode} mode):`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (let i = 0; i < skills.length; i++) {
|
||||
const skill = skills[i];
|
||||
const preview = skill.content.length > 80
|
||||
? skill.content.slice(0, 80).trim() + "..."
|
||||
: skill.content.trim();
|
||||
lines.push(`[#${i}] ${skill.name} — ${preview}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
64
gadget-drone/src/tools/project/read-skill.ts
Normal file
64
gadget-drone/src/tools/project/read-skill.ts
Normal file
@ -0,0 +1,64 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import type { IAiLogger, IToolArguments } from "@gadget/ai";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
|
||||
export class ReadSkillTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "read_skill";
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "project";
|
||||
}
|
||||
|
||||
public definition = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"Read the full content of a project skill by its index number. Use list_skills() first to see available skills and their indices.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
index: {
|
||||
type: "number",
|
||||
description: "The skill index from list_skills()",
|
||||
},
|
||||
},
|
||||
required: ["index"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public async execute(args: IToolArguments, _logger: IAiLogger): Promise<string> {
|
||||
const project = this.toolbox.env.project;
|
||||
if (!project) {
|
||||
return "No project is configured for this session.";
|
||||
}
|
||||
|
||||
const index = typeof args.index === "number" ? args.index : parseInt(String(args.index), 10);
|
||||
if (isNaN(index)) {
|
||||
return "Invalid index. Provide a numeric index from list_skills().";
|
||||
}
|
||||
|
||||
const mode = this.toolbox.env.chatSessionMode;
|
||||
const skills = project.skills.filter(
|
||||
(s) => mode && s.modes.includes(mode),
|
||||
);
|
||||
|
||||
if (index < 0 || index >= skills.length) {
|
||||
return `Skill index ${index} not found. Use list_skills() to see available skills.`;
|
||||
}
|
||||
|
||||
const skill = skills[index];
|
||||
const lines: string[] = [
|
||||
`Skill: ${skill.name} (${mode} mode)`,
|
||||
"",
|
||||
skill.content,
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import type { IAiTool } from "@gadget/ai";
|
||||
import type { IProject, ChatSessionMode } from "@gadget/api";
|
||||
|
||||
export interface DroneToolboxEnvironment {
|
||||
NODE_ENV: string;
|
||||
@ -18,6 +19,8 @@ export interface DroneToolboxEnvironment {
|
||||
projectDir?: string;
|
||||
cacheDir?: string;
|
||||
};
|
||||
project?: IProject;
|
||||
chatSessionMode?: ChatSessionMode;
|
||||
}
|
||||
|
||||
export type ToolMap = Map<string, IAiTool>;
|
||||
@ -40,6 +43,11 @@ export class AiToolbox {
|
||||
this._env.workspace = workspace;
|
||||
}
|
||||
|
||||
updateProjectContext(project: IProject, mode: ChatSessionMode): void {
|
||||
this._env.project = project;
|
||||
this._env.chatSessionMode = mode;
|
||||
}
|
||||
|
||||
register(tool: IAiTool, modes?: string[]): void {
|
||||
if (this.tools.has(tool.name)) {
|
||||
throw new Error(`tool already registered: ${tool.name}`);
|
||||
|
||||
@ -3,8 +3,11 @@
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import type { IUser } from "./user.js";
|
||||
import type { IAiProvider } from "./ai-provider.ts";
|
||||
|
||||
import { GadgetId } from "../lib/gadget-id.ts";
|
||||
import { HydratedDocument } from "mongoose";
|
||||
import { ChatSessionMode, IChatSession } from "./chat-session.ts";
|
||||
|
||||
export enum ProjectStatus {
|
||||
Active = "active",
|
||||
@ -12,6 +15,25 @@ export enum ProjectStatus {
|
||||
Archived = "archived",
|
||||
}
|
||||
|
||||
export interface IProjectSkill {
|
||||
_id: GadgetId;
|
||||
name: string;
|
||||
content: string;
|
||||
modes: ChatSessionMode[];
|
||||
}
|
||||
|
||||
export interface IProjectTask {
|
||||
_id: GadgetId;
|
||||
name: string;
|
||||
provider: IAiProvider | GadgetId;
|
||||
selectedModel: string;
|
||||
mode: ChatSessionMode;
|
||||
crontab: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
lastRun: IChatSession | GadgetId;
|
||||
}
|
||||
|
||||
export interface IProject {
|
||||
_id: GadgetId;
|
||||
createdAt: Date;
|
||||
@ -20,6 +42,10 @@ export interface IProject {
|
||||
name: string;
|
||||
slug: string;
|
||||
gitUrl?: string;
|
||||
description?: string;
|
||||
system?: string;
|
||||
skills: IProjectSkill[];
|
||||
tasks: IProjectTask[];
|
||||
}
|
||||
|
||||
export type ProjectDocument = HydratedDocument<IProject>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user