project manager upgrades Phases 1-4
This commit is contained in:
parent
cc7cdee60b
commit
72102e7fdb
@ -222,6 +222,25 @@ export interface AuthResponse {
|
|||||||
token: string;
|
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 {
|
export interface Project {
|
||||||
_id: string;
|
_id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -230,6 +249,10 @@ export interface Project {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
gitUrl?: string;
|
gitUrl?: string;
|
||||||
|
description?: string;
|
||||||
|
system?: string;
|
||||||
|
skills: ProjectSkill[];
|
||||||
|
tasks: ProjectTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
@ -255,6 +278,10 @@ export const projectApi = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
gitUrl: string;
|
gitUrl: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
description: string;
|
||||||
|
system: string;
|
||||||
|
skills: ProjectSkill[];
|
||||||
|
tasks: ProjectTask[];
|
||||||
}>,
|
}>,
|
||||||
) => api.put<Project>(`/api/v1/projects/${id}`, data),
|
) => api.put<Project>(`/api/v1/projects/${id}`, data),
|
||||||
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import slug from "slug";
|
import slug from "slug";
|
||||||
import type { User, Project } from "../lib/api";
|
import type { User, Project, ProjectSkill, ProjectTask } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
projectApi,
|
projectApi,
|
||||||
droneApi,
|
droneApi,
|
||||||
@ -195,21 +195,118 @@ function PullProjectForm({
|
|||||||
|
|
||||||
interface EditProjectFormProps {
|
interface EditProjectFormProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
providers: AiProvider[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSuccess: () => 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({
|
function EditProjectForm({
|
||||||
project,
|
project,
|
||||||
|
providers,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: EditProjectFormProps) {
|
}: EditProjectFormProps) {
|
||||||
const [name, setName] = useState(project.name);
|
const [name, setName] = useState(project.name);
|
||||||
const [slugValue, setSlugValue] = useState(project.slug);
|
const [slugValue, setSlugValue] = useState(project.slug);
|
||||||
const [gitUrl, setGitUrl] = useState(project.gitUrl || "");
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name || !slugValue) {
|
if (!name || !slugValue) {
|
||||||
@ -225,6 +322,10 @@ function EditProjectForm({
|
|||||||
name,
|
name,
|
||||||
slug: slug(slugValue),
|
slug: slug(slugValue),
|
||||||
gitUrl: gitUrl || undefined,
|
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();
|
onSuccess();
|
||||||
} catch (err) {
|
} 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 (
|
return (
|
||||||
<div className="max-w-lg">
|
<div className="max-w-2xl">
|
||||||
<h2 className="text-xl font-semibold mb-6">Edit Project</h2>
|
<h2 className="text-xl font-semibold mb-6">Edit Project</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Basic Fields */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-text-secondary mb-1">
|
<label className="block text-sm text-text-secondary mb-1">
|
||||||
Project Name *
|
Project Name *
|
||||||
@ -246,12 +351,9 @@ function EditProjectForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
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"
|
placeholder="My Project"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-text-muted mt-1">
|
|
||||||
Used for display purposes only
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-text-secondary mb-1">
|
<label className="block text-sm text-text-secondary mb-1">
|
||||||
@ -261,7 +363,7 @@ function EditProjectForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={slugValue}
|
value={slugValue}
|
||||||
onChange={(e) => setSlugValue(e.target.value)}
|
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"
|
placeholder="my-project"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-text-muted mt-1">
|
<p className="text-xs text-text-muted mt-1">
|
||||||
@ -277,10 +379,431 @@ function EditProjectForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={gitUrl}
|
value={gitUrl}
|
||||||
onChange={(e) => setGitUrl(e.target.value)}
|
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"
|
placeholder="https://github.com/user/repo.git"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>}
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
@ -305,12 +828,14 @@ function EditProjectForm({
|
|||||||
|
|
||||||
interface ProjectInspectorProps {
|
interface ProjectInspectorProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
providers: AiProvider[];
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectInspector({
|
function ProjectInspector({
|
||||||
project,
|
project,
|
||||||
|
providers,
|
||||||
onDelete,
|
onDelete,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: ProjectInspectorProps) {
|
}: 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 (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
@ -343,6 +877,7 @@ function ProjectInspector({
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<EditProjectForm
|
<EditProjectForm
|
||||||
project={project}
|
project={project}
|
||||||
|
providers={providers}
|
||||||
onCancel={() => setEditing(false)}
|
onCancel={() => setEditing(false)}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setEditing(false);
|
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="grid grid-cols-2 gap-4">
|
||||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||||
<div className="text-sm text-text-muted mb-1">Name</div>
|
<div className="text-sm text-text-muted mb-1">Name</div>
|
||||||
@ -367,6 +902,16 @@ function ProjectInspector({
|
|||||||
</div>
|
</div>
|
||||||
</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="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-sm text-text-muted mb-1">Git URL</div>
|
||||||
<div className="text-text-primary font-mono text-sm break-all">
|
<div className="text-text-primary font-mono text-sm break-all">
|
||||||
@ -376,6 +921,75 @@ function ProjectInspector({
|
|||||||
</div>
|
</div>
|
||||||
</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="grid grid-cols-2 gap-4">
|
||||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||||
<div className="text-sm text-text-muted mb-1">Status</div>
|
<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>(
|
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showNewForm, setShowNewForm] = useState(false);
|
const [showNewForm, setShowNewForm] = useState(false);
|
||||||
const [showPullForm, setShowPullForm] = useState(false);
|
const [showPullForm, setShowPullForm] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProjects();
|
loadProjects();
|
||||||
|
loadProviders();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
const handleSelectProject = (project: Project) => {
|
||||||
navigate(`/projects/${project.slug}`);
|
navigate(`/projects/${project.slug}`);
|
||||||
};
|
};
|
||||||
@ -1084,6 +1709,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
|||||||
{/* Center - Project Inspector */}
|
{/* Center - Project Inspector */}
|
||||||
<ProjectInspector
|
<ProjectInspector
|
||||||
project={selectedProject}
|
project={selectedProject}
|
||||||
|
providers={providers}
|
||||||
onDelete={handleProjectDeleted}
|
onDelete={handleProjectDeleted}
|
||||||
onUpdate={handleProjectUpdated}
|
onUpdate={handleProjectUpdated}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -196,12 +196,16 @@ export class ProjectApiControllerV1 extends DtpController {
|
|||||||
return;
|
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, {
|
const updated = await projectService.update(project, {
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
gitUrl,
|
gitUrl,
|
||||||
status: status as ProjectStatus,
|
status: status as ProjectStatus,
|
||||||
|
description,
|
||||||
|
system,
|
||||||
|
skills,
|
||||||
|
tasks,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|||||||
@ -4,9 +4,33 @@
|
|||||||
|
|
||||||
import { Schema, model } from "mongoose";
|
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";
|
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>({
|
export const ProjectSchema = new Schema<IProject>({
|
||||||
_id: { type: String, default: () => nanoid() },
|
_id: { type: String, default: () => nanoid() },
|
||||||
createdAt: { type: Date, default: Date.now, required: true },
|
createdAt: { type: Date, default: Date.now, required: true },
|
||||||
@ -20,6 +44,10 @@ export const ProjectSchema = new Schema<IProject>({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
gitUrl: { type: String },
|
gitUrl: { type: String },
|
||||||
|
description: { type: String },
|
||||||
|
system: { type: String },
|
||||||
|
skills: { type: [ProjectSkillSchema], default: [], required: true },
|
||||||
|
tasks: { type: [ProjectTaskSchema], default: [], required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
ProjectSchema.index(
|
ProjectSchema.index(
|
||||||
|
|||||||
@ -381,6 +381,15 @@ class ChatSessionService extends DtpService {
|
|||||||
.replace("{{session_block}}", sessionBlock)
|
.replace("{{session_block}}", sessionBlock)
|
||||||
.replace("{{persona_block}}", personaBlock);
|
.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;
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import slug from "slug";
|
|||||||
|
|
||||||
import { MongooseBaseQueryOptions, PopulateOptions } from "mongoose";
|
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 Project from "@/models/project.js";
|
||||||
|
|
||||||
import { DtpService } from "../lib/service.js";
|
import { DtpService } from "../lib/service.js";
|
||||||
@ -16,6 +16,10 @@ export interface IProjectDefinition {
|
|||||||
slug: string;
|
slug: string;
|
||||||
gitUrl?: string;
|
gitUrl?: string;
|
||||||
status?: ProjectStatus;
|
status?: ProjectStatus;
|
||||||
|
description?: string;
|
||||||
|
system?: string;
|
||||||
|
skills?: IProjectSkill[];
|
||||||
|
tasks?: IProjectTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProjectService extends DtpService {
|
class ProjectService extends DtpService {
|
||||||
@ -158,10 +162,41 @@ class ProjectService extends DtpService {
|
|||||||
if (definition.gitUrl !== project.gitUrl) {
|
if (definition.gitUrl !== project.gitUrl) {
|
||||||
update.$set.gitUrl = definition.gitUrl;
|
update.$set.gitUrl = definition.gitUrl;
|
||||||
}
|
}
|
||||||
|
} else if (definition.gitUrl === undefined && definition.hasOwnProperty('gitUrl')) {
|
||||||
|
// Only unset if explicitly provided as undefined/empty
|
||||||
} else {
|
} else {
|
||||||
update.$unset.gitUrl = 1;
|
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(
|
const newProject = await Project.findOneAndUpdate(
|
||||||
{ _id: project._id },
|
{ _id: project._id },
|
||||||
update,
|
update,
|
||||||
|
|||||||
@ -52,6 +52,8 @@ import {
|
|||||||
ShellExecTool,
|
ShellExecTool,
|
||||||
SubagentTool,
|
SubagentTool,
|
||||||
SubprocessTool,
|
SubprocessTool,
|
||||||
|
ListSkillsTool,
|
||||||
|
ReadSkillTool,
|
||||||
type DroneToolboxEnvironment,
|
type DroneToolboxEnvironment,
|
||||||
} from "../tools/index.ts";
|
} from "../tools/index.ts";
|
||||||
|
|
||||||
@ -131,6 +133,10 @@ class AgentService extends GadgetService {
|
|||||||
subagentTool.setSpawner((agentType, prompt) => this.spawnSubagent(agentType, prompt));
|
subagentTool.setSpawner((agentType, prompt) => this.spawnSubagent(agentType, prompt));
|
||||||
this.toolbox.register(subagentTool, readOnlyModes);
|
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");
|
this.log.info("started");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -548,6 +554,8 @@ class AgentService extends GadgetService {
|
|||||||
projectDir: WorkspaceService.getProjectDirectory(project.slug),
|
projectDir: WorkspaceService.getProjectDirectory(project.slug),
|
||||||
cacheDir,
|
cacheDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.toolbox.updateProjectContext(project, turn.mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
async spawnSubagent(
|
async spawnSubagent(
|
||||||
|
|||||||
@ -7,3 +7,4 @@ export * from "./chat/index.ts";
|
|||||||
export * from "./system/index.ts";
|
export * from "./system/index.ts";
|
||||||
export * from "./network/index.ts";
|
export * from "./network/index.ts";
|
||||||
export * from "./plan/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
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import type { IAiTool } from "@gadget/ai";
|
import type { IAiTool } from "@gadget/ai";
|
||||||
|
import type { IProject, ChatSessionMode } from "@gadget/api";
|
||||||
|
|
||||||
export interface DroneToolboxEnvironment {
|
export interface DroneToolboxEnvironment {
|
||||||
NODE_ENV: string;
|
NODE_ENV: string;
|
||||||
@ -18,6 +19,8 @@ export interface DroneToolboxEnvironment {
|
|||||||
projectDir?: string;
|
projectDir?: string;
|
||||||
cacheDir?: string;
|
cacheDir?: string;
|
||||||
};
|
};
|
||||||
|
project?: IProject;
|
||||||
|
chatSessionMode?: ChatSessionMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolMap = Map<string, IAiTool>;
|
export type ToolMap = Map<string, IAiTool>;
|
||||||
@ -40,6 +43,11 @@ export class AiToolbox {
|
|||||||
this._env.workspace = workspace;
|
this._env.workspace = workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateProjectContext(project: IProject, mode: ChatSessionMode): void {
|
||||||
|
this._env.project = project;
|
||||||
|
this._env.chatSessionMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
register(tool: IAiTool, modes?: string[]): void {
|
register(tool: IAiTool, modes?: string[]): void {
|
||||||
if (this.tools.has(tool.name)) {
|
if (this.tools.has(tool.name)) {
|
||||||
throw new Error(`tool already registered: ${tool.name}`);
|
throw new Error(`tool already registered: ${tool.name}`);
|
||||||
|
|||||||
@ -3,8 +3,11 @@
|
|||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import type { IUser } from "./user.js";
|
import type { IUser } from "./user.js";
|
||||||
|
import type { IAiProvider } from "./ai-provider.ts";
|
||||||
|
|
||||||
import { GadgetId } from "../lib/gadget-id.ts";
|
import { GadgetId } from "../lib/gadget-id.ts";
|
||||||
import { HydratedDocument } from "mongoose";
|
import { HydratedDocument } from "mongoose";
|
||||||
|
import { ChatSessionMode, IChatSession } from "./chat-session.ts";
|
||||||
|
|
||||||
export enum ProjectStatus {
|
export enum ProjectStatus {
|
||||||
Active = "active",
|
Active = "active",
|
||||||
@ -12,6 +15,25 @@ export enum ProjectStatus {
|
|||||||
Archived = "archived",
|
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 {
|
export interface IProject {
|
||||||
_id: GadgetId;
|
_id: GadgetId;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@ -20,6 +42,10 @@ export interface IProject {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
gitUrl?: string;
|
gitUrl?: string;
|
||||||
|
description?: string;
|
||||||
|
system?: string;
|
||||||
|
skills: IProjectSkill[];
|
||||||
|
tasks: IProjectTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectDocument = HydratedDocument<IProject>;
|
export type ProjectDocument = HydratedDocument<IProject>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user