Skip to content

Commit f87b582

Browse files
new copilot skill format (#34)
* new copilot skill format * use a common function to buildAndUpdateView
1 parent 9914fe6 commit f87b582

7 files changed

Lines changed: 442 additions & 279 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,3 +439,12 @@ export default function App({ viewName, query }) {
439439
# Copilot
440440
441441
The Saltcorn copilot can generate react-views. Only views where all the code is stored within the view are possible, an action to change the main bundle does not exist yet. When the chat only gives you the code without a button to apply it, try to be more explicit (for example, it could be that the model still needs to know the min_role).
442+
443+
## Setup
444+
445+
To make the **Generate React View** tool available in the Saltcorn Copilot:
446+
447+
1. Make sure **Agents** is installed.
448+
2. Go to **Actions** and create a new Trigger with the **Agent** action.
449+
3. In the configuration, add **Generate React View** to the skill list.
450+
4. Open the **Saltcorn Copilot** trigger configuration, click the add button and select your **Generate React View** trigger.

‎agent-skill.js‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
const View = require("@saltcorn/data/models/view");
2+
const Table = require("@saltcorn/data/models/table");
3+
const User = require("@saltcorn/data/models/user");
4+
const { a, pre, div, code } = require("@saltcorn/markup/tags");
5+
const {
6+
buildAndUpdateView,
7+
escapeHtml,
8+
reactViewSystemPrompt,
9+
} = require("./common");
10+
11+
class GenerateReactViewSkill {
12+
static skill_name = "Generate React View";
13+
14+
get skill_label() {
15+
return "React View";
16+
}
17+
18+
constructor(cfg) {
19+
Object.assign(this, cfg);
20+
}
21+
22+
static async configFields() {
23+
return [];
24+
}
25+
26+
async systemPrompt() {
27+
const roles = await User.get_roles();
28+
const tables = await Table.find({});
29+
30+
const tableList = tables.map((t) => t.name).join(", ");
31+
const roleList = roles.map((r) => r.role).join(", ");
32+
33+
return (
34+
reactViewSystemPrompt +
35+
`Available tables: ${tableList || "none"}
36+
Available roles (for min_role): ${roleList}
37+
`
38+
);
39+
}
40+
41+
provideTools() {
42+
return [
43+
{
44+
type: "function",
45+
renderToolCall({ view_name, react_code }) {
46+
return (
47+
div({ class: "mb-3" }, view_name) +
48+
pre(code(escapeHtml(react_code)))
49+
);
50+
},
51+
process: async ({
52+
react_code,
53+
view_name,
54+
table,
55+
view_description,
56+
min_role,
57+
}) => {
58+
const roles = await User.get_roles();
59+
const min_role_id = min_role
60+
? roles.find((r) => r.role === min_role)?.id ?? 100
61+
: 100;
62+
const viewCfg = {
63+
name: view_name,
64+
viewtemplate: "React",
65+
min_role: min_role_id,
66+
configuration: { build_mode: "production", user_code: react_code },
67+
};
68+
if (table) {
69+
const tableObj = Table.findOne({ name: table });
70+
if (!tableObj) throw new Error(`Table ${table} not found`);
71+
viewCfg.table_id = tableObj.id;
72+
}
73+
if (view_description) viewCfg.description = view_description;
74+
await View.create(viewCfg);
75+
await buildAndUpdateView(react_code, "production", view_name);
76+
return { view_name };
77+
},
78+
renderToolResponse: async ({ view_name }) => {
79+
return (
80+
"View created. " +
81+
a(
82+
{ target: "_blank", href: `/view/${view_name}`, class: "me-1" },
83+
"Go to view"
84+
) +
85+
" | " +
86+
a(
87+
{
88+
target: "_blank",
89+
href: `/viewedit/config/${view_name}`,
90+
class: "ms-1",
91+
},
92+
"Configure view"
93+
)
94+
);
95+
},
96+
function: {
97+
name: "generate_react_view",
98+
description: "Generate and create a React View in Saltcorn",
99+
parameters: {
100+
type: "object",
101+
required: ["react_code", "view_name"],
102+
properties: {
103+
react_code: {
104+
description: `JavaScript code that constitutes the react component.
105+
You must include the react import at the top, and the code should export default the component.`,
106+
type: "string",
107+
},
108+
view_name: {
109+
description: `The name of the view, this should be a short name which is part of the url.`,
110+
type: "string",
111+
},
112+
table: {
113+
description:
114+
"Which table is this a view on (optional, omit for tableless views)",
115+
type: "string",
116+
},
117+
view_description: {
118+
description: "A description of the purpose of the view.",
119+
type: "string",
120+
},
121+
min_role: {
122+
description:
123+
"The minimum role needed to access the view. Use 'admin' for admin-only, 'public' for publicly accessible.",
124+
type: "string",
125+
},
126+
},
127+
},
128+
},
129+
},
130+
];
131+
}
132+
}
133+
134+
module.exports = GenerateReactViewSkill;

‎common.js‎

Lines changed: 195 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const Workflow = require("@saltcorn/data/models/workflow");
22
const Form = require("@saltcorn/data/models/form");
33
const Table = require("@saltcorn/data/models/table");
4+
const View = require("@saltcorn/data/models/view");
45
const { div, script } = require("@saltcorn/markup/tags");
56
const { getState } = require("@saltcorn/data/db/state");
67
const {
@@ -33,7 +34,7 @@ const buildViewBundle = async (buildMode, viewName, timestamp) => {
3334
],
3435
{
3536
cwd: __dirname,
36-
},
37+
}
3738
);
3839
child.stdout.on("data", (data) => {
3940
getState().log(5, data.toString());
@@ -59,7 +60,7 @@ const handleUserCode = async (
5960
buildMode,
6061
viewName,
6162
oldTimestamp,
62-
newTimestamp,
63+
newTimestamp
6364
) => {
6465
const tenant = db.getTenantSchema() || "public";
6566
const userCodeDir = path.join(__dirname, "user-code", tenant);
@@ -72,25 +73,214 @@ const handleUserCode = async (
7273
await fs.writeFile(
7374
path.join(userCodeDir, `${safeViewName}.js`),
7475
userCode,
75-
"utf8",
76+
"utf8"
7677
);
7778
if ((await buildViewBundle(buildMode, safeViewName, newTimestamp)) !== 0) {
7879
throw new Error("Build failed please check your server logs");
7980
}
8081
try {
8182
await fs.rm(
8283
path.join(__dirname, "public", tenant, `${safeViewName}_${oldTimestamp}`),
83-
{ recursive: true, force: true },
84+
{ recursive: true, force: true }
8485
);
8586
} catch (err) {
8687
getState().log(
8788
2,
88-
"Error removing old directory: " + err.message || "Unknown error",
89+
"Error removing old directory: " + err.message || "Unknown error"
8990
);
9091
}
9192
};
9293

94+
const reactViewSystemPrompt = `Use the generate_react_view tool to generate a react view. Here is an example of a
95+
valid generated react code:
96+
97+
\`\`\`import React from "react";
98+
99+
export default function App({}) {
100+
return <h1>Hello world</h1>;
101+
}
102+
\`\`\`
103+
104+
The generated code must include the react import at the top, and your generated code should export default the component.
105+
106+
A react view can be tableless or table-based. A tableless react view could for example show the current time, a table based view could show the data of one or multiple persons.
107+
108+
When a react view is tabless, it gets this properties:
109+
\`\`\`import React from "react";
110+
export default function App({viewName, query}) {...}
111+
\`\`\`
112+
When a react view is table based, it gets this properties:
113+
\`\`\`import React from "react";
114+
export default function App({viewName, query, tableName, rows, state}) {...}
115+
\`\`\`
116+
- viewName: the name of the view
117+
- query: the query parameters of the view
118+
- tableName: the name of the Saltcorn table
119+
- rows: the rows of the table, this is an array of objects, each object is a row of the table
120+
- state: the state of the view, this is an object with the state of the view
121+
122+
A react-view has access to bootstrap 5 styles. react-bootstrap is not available please use the normal bootstrap classes.
123+
124+
A react-view can use the function set_state_field(key, value, e) to change the current query of the browser window.
125+
By changing the query you can act as a filter. For example if you show a list of persons, you can filter by name, age, etc.
126+
Key is the name of the field, value is the value you want to set, and e is the event that triggered the change.
127+
A react-filter-view can exist independent of other views, it only has to call set_state_field.
128+
129+
A react-view hast accees to the react-lib npm package. This is a module with hooks and functions to interact with the Saltcorn system. You can import it like this:
130+
\`\`\`
131+
import { useFetchOneRow, useFetchRows } from "@saltcorn/react-lib/hooks";
132+
import { fetchOneRow, fetchRows, insertRow, updateRow, deleteRow } from "@saltcorn/react-lib/api";
133+
\`\`\`
134+
Please note that hooks are in the hooks submodule and functions are in the api submodule.
135+
136+
useFetchOneRow and useFetchRows are hooks to fetch rows from a Saltcorn table. Table-based views receive the initial rows in the rows property,
137+
useFetchOneRow and useFetchRows can load data at runtime. This can be useful when the initial data changes,
138+
or for loading data from another table than the table of the view, or for tableless views.
139+
140+
Parameters of useFetchOneRow:
141+
- tableName: the name of the table
142+
- query: the query to fetch the row as an object
143+
- dependencies: an array of dependencies, when one of the dependencies changes, useFetchOneRow will fetch the row again
144+
Returns an object with:
145+
- isLoading: boolean indicating if the data is loading
146+
- error: string describing the error or null
147+
- row: an object with the data of the row
148+
149+
useFetchRows has the same signature, but returns an array of rows instead of a single row.
150+
151+
Example of useFetchRows:
152+
\`\`\`
153+
import React, { useState } from "react";
154+
import { useFetchRows } from "@saltcorn/react-lib/hooks";
155+
export default function App({query, tableName}) {
156+
const { rows, isLoading, error } = useFetchRows("persons", query || {});
157+
if (isLoading) return <div>Loading...</div>;
158+
if (error) return <div>Error: {error}</div>;
159+
return (
160+
<div>
161+
{rows.map((row) => (
162+
<div key={row.id}>
163+
{row.name} - {row.age}
164+
</div>
165+
))}
166+
</div>
167+
);
168+
}
169+
170+
When you do not want to use the hooks, you can use fetchOneRow and fetchRows. fetchRows example:
171+
\`\`\`
172+
import React, { useState, useEffect } from "react";
173+
import { fetchOneRow, fetchRows } from "@saltcorn/react-lib/api";
174+
export default function App({query, tableName}) {
175+
// load rows once
176+
const [rows, setRows] = useState([]);
177+
useEffect(() => {
178+
fetchRows(tableName, query).then((rows) => setRows(rows));
179+
}, [tableName, query]);
180+
}
181+
\`\`\`
182+
fetchRows returns an empty array if nothing was found and throws an error if something goes wrong.
183+
184+
fetchOneRow example:
185+
\`\`\`
186+
import React, { useState, useEffect } from "react";
187+
import { fetchOneRow } from "@saltcorn/react-lib/api";
188+
export default function App({query, tableName}) {
189+
// load row once
190+
const [row, setRow] = useState(null);
191+
useEffect(() => {
192+
fetchOneRow(tableName, query).then((row) => setRow(row));
193+
}, [tableName, query]);
194+
}
195+
\`\`\`
196+
fetchOneRow returns null if nothing was found and throws an error if something goes wrong.
197+
198+
Under "@saltcorn/react-lib/api" you also find the functions insertRow, updateRow and deleteRow.
199+
They all throw an error if something goes wrong.
200+
201+
Parameters of insertRow:
202+
- tableName: the name of the table
203+
- row: the row to insert as an object
204+
Example:
205+
\`\`\`
206+
import React, { useState } from "react";
207+
import { insertRow } from "@saltcorn/react-lib/api";
208+
export default function App({query, tableName}) {
209+
const [row, setRow] = useState(null);
210+
const handleInsert = () => {
211+
insertRow(tableName, row).then((row) => setRow(row));
212+
}
213+
}
214+
\`\`\`
215+
216+
Parameters of updateRow:
217+
- tableName: the name of the table
218+
- id: the id of the row to update
219+
- row: the row to update as an object
220+
\`\`\`
221+
import React, { useState } from "react";
222+
import { updateRow } from "@saltcorn/react-lib/api";
223+
export default function App({query, tableName}) {
224+
const [row, setRow] = useState(null);
225+
const handleUpdate = () => {
226+
updateRow(tableName, row).then((row) => setRow(row));
227+
}
228+
}
229+
\`\`\`
230+
231+
Parameters of deleteRow:
232+
- tableName: the name of the table
233+
- id: the id of the row to delete
234+
Example:
235+
\`\`\`
236+
import React, { useState } from "react";
237+
import { deleteRow } from "@saltcorn/react-lib/api";
238+
export default function App({query, tableName}) {
239+
const [row, setRow] = useState(null);
240+
const handleDelete = () => {
241+
deleteRow(tableName, row.id).then(() => setRow(null));
242+
}
243+
}
244+
\`\`\`
245+
246+
`;
247+
248+
const escapeHtml = (unsafe) =>
249+
unsafe
250+
.replace(/&/g, "&amp;")
251+
.replace(/</g, "&lt;")
252+
.replace(/>/g, "&gt;")
253+
.replace(/"/g, "&quot;")
254+
.replace(/'/g, "&#039;");
255+
256+
const buildAndUpdateView = async (
257+
user_code,
258+
build_mode,
259+
viewname,
260+
oldTimestamp
261+
) => {
262+
const newTimestamp = new Date().valueOf();
263+
await handleUserCode(
264+
user_code,
265+
build_mode,
266+
viewname,
267+
oldTimestamp,
268+
newTimestamp
269+
);
270+
const view = View.findOne({ name: viewname });
271+
await View.update(
272+
{
273+
configuration: { ...(view.configuration || {}), timestamp: newTimestamp },
274+
},
275+
view.id
276+
);
277+
await getState().refresh_views();
278+
};
279+
93280
module.exports = {
94281
buildSafeViewName,
95282
handleUserCode,
283+
buildAndUpdateView,
284+
escapeHtml,
285+
reactViewSystemPrompt,
96286
};

0 commit comments

Comments
 (0)