@schlessera/brain-ui-react 0.27.0 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/chat/chat-page.js +2 -2
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/tool-call-timeline.d.ts +1 -1
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
- package/dist/components/chat/tool-call-timeline.js +1 -1
- package/dist/components/chat/tool-call-timeline.js.map +1 -1
- package/dist/components/settings/models-tab.d.ts.map +1 -1
- package/dist/components/settings/models-tab.js +2 -1
- package/dist/components/settings/models-tab.js.map +1 -1
- package/dist/components/settings/settings-panel.d.ts.map +1 -1
- package/dist/components/settings/settings-panel.js +4 -2
- package/dist/components/settings/settings-panel.js.map +1 -1
- package/dist/components/settings/skills-tab.d.ts +4 -0
- package/dist/components/settings/skills-tab.d.ts.map +1 -0
- package/dist/components/settings/skills-tab.js +150 -0
- package/dist/components/settings/skills-tab.js.map +1 -0
- package/dist/components/settings/tool-permissions.d.ts +10 -0
- package/dist/components/settings/tool-permissions.d.ts.map +1 -0
- package/dist/components/settings/tool-permissions.js +41 -0
- package/dist/components/settings/tool-permissions.js.map +1 -0
- package/dist/hooks/use-websocket.js +1 -1
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/lib/api-client.d.ts +65 -0
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +46 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/stores/chat-store.d.ts +6 -1
- package/dist/stores/chat-store.d.ts.map +1 -1
- package/dist/stores/chat-store.js +2 -1
- package/dist/stores/chat-store.js.map +1 -1
- package/dist/stores/ui-store.d.ts +1 -1
- package/dist/stores/ui-store.d.ts.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/chat-page.tsx +2 -2
- package/src/components/chat/tool-call-timeline.tsx +16 -4
- package/src/components/settings/models-tab.tsx +3 -0
- package/src/components/settings/settings-panel.tsx +5 -1
- package/src/components/settings/skills-tab.tsx +389 -0
- package/src/components/settings/tool-permissions.tsx +72 -0
- package/src/hooks/use-websocket.ts +1 -1
- package/src/lib/api-client.ts +94 -0
- package/src/stores/chat-store.ts +9 -2
- package/src/stores/ui-store.ts +1 -1
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Check, Download, Loader2, Pencil, Plus, Power, Trash2, Upload, X } from "lucide-react";
|
|
3
|
+
import { api, type SkillDetail, type SkillEntry, type SkillInstallOutcome } from "../../lib/api-client.js";
|
|
4
|
+
import { cn } from "../../lib/utils.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Settings → Skills: manage the user's CUSTOM skills — create, edit,
|
|
8
|
+
* enable/disable, remove — plus a read-only view of the built-ins brain-kit
|
|
9
|
+
* ships. Custom skills live in the brain repo's `.agents/skills/` (real
|
|
10
|
+
* directories), so they persist across deployments, ride the repo's git
|
|
11
|
+
* backup, and reach every backend; a save runs `brain skills sync`
|
|
12
|
+
* server-side so the change applies to the next turn without a restart.
|
|
13
|
+
*
|
|
14
|
+
* For guided authoring there is a better surface than this editor: the
|
|
15
|
+
* `add-skill` skill — ask the agent in chat.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const TEMPLATE = (name: string) => `---
|
|
19
|
+
name: ${name}
|
|
20
|
+
description: Use when … (write the TRIGGER, not a summary — this line is how agents decide to load the skill)
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# ${name}
|
|
24
|
+
|
|
25
|
+
Instructions for the agent. Keep them imperative and specific.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
export function SkillsTab({ active }: { active: boolean }) {
|
|
29
|
+
const [skills, setSkills] = useState<SkillEntry[]>([]);
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
const [warning, setWarning] = useState<string | null>(null);
|
|
32
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
33
|
+
const [editing, setEditing] = useState<{
|
|
34
|
+
name: string;
|
|
35
|
+
content: string;
|
|
36
|
+
isNew: boolean;
|
|
37
|
+
readOnly: boolean;
|
|
38
|
+
} | null>(null);
|
|
39
|
+
const [newName, setNewName] = useState("");
|
|
40
|
+
const [githubSource, setGithubSource] = useState("");
|
|
41
|
+
const [overwrite, setOverwrite] = useState(false);
|
|
42
|
+
const [installing, setInstalling] = useState(false);
|
|
43
|
+
const [outcomes, setOutcomes] = useState<SkillInstallOutcome[] | null>(null);
|
|
44
|
+
|
|
45
|
+
async function reload() {
|
|
46
|
+
try {
|
|
47
|
+
const { skills } = await api.skillsList();
|
|
48
|
+
setSkills(skills);
|
|
49
|
+
setError(null);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setError(err instanceof Error ? err.message : "Could not load skills");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (active) void reload();
|
|
57
|
+
}, [active]);
|
|
58
|
+
|
|
59
|
+
async function run(name: string, fn: () => Promise<{ warning?: string } | void>) {
|
|
60
|
+
setBusy(name);
|
|
61
|
+
setError(null);
|
|
62
|
+
setWarning(null);
|
|
63
|
+
try {
|
|
64
|
+
const result = await fn();
|
|
65
|
+
if (result && "warning" in result && result.warning) setWarning(result.warning);
|
|
66
|
+
await reload();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
setError(err instanceof Error ? err.message : "Skill operation failed");
|
|
69
|
+
} finally {
|
|
70
|
+
setBusy(null);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function openEditor(entry: SkillEntry) {
|
|
75
|
+
setBusy(entry.name);
|
|
76
|
+
try {
|
|
77
|
+
const detail: SkillDetail = await api.skillGet(entry.name);
|
|
78
|
+
setEditing({
|
|
79
|
+
name: entry.name,
|
|
80
|
+
content: detail.content,
|
|
81
|
+
isNew: false,
|
|
82
|
+
readOnly: entry.source === "builtin",
|
|
83
|
+
});
|
|
84
|
+
setError(null);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
setError(err instanceof Error ? err.message : "Could not load skill");
|
|
87
|
+
} finally {
|
|
88
|
+
setBusy(null);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function startCreate() {
|
|
93
|
+
const name = newName.trim();
|
|
94
|
+
if (!name) return;
|
|
95
|
+
setEditing({ name, content: TEMPLATE(name), isNew: true, readOnly: false });
|
|
96
|
+
setNewName("");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function saveEditor() {
|
|
100
|
+
if (!editing || editing.readOnly) return;
|
|
101
|
+
const { name, content, isNew } = editing;
|
|
102
|
+
await run(name, async () => {
|
|
103
|
+
const result = isNew
|
|
104
|
+
? await api.skillCreate(name, content)
|
|
105
|
+
: await api.skillUpdate(name, content);
|
|
106
|
+
setEditing(null);
|
|
107
|
+
return result;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function install(fn: () => Promise<{ outcomes: SkillInstallOutcome[]; warning?: string }>) {
|
|
112
|
+
setInstalling(true);
|
|
113
|
+
setError(null);
|
|
114
|
+
setWarning(null);
|
|
115
|
+
setOutcomes(null);
|
|
116
|
+
try {
|
|
117
|
+
const result = await fn();
|
|
118
|
+
setOutcomes(result.outcomes);
|
|
119
|
+
if (result.warning) setWarning(result.warning);
|
|
120
|
+
await reload();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
setError(err instanceof Error ? err.message : "Install failed");
|
|
123
|
+
} finally {
|
|
124
|
+
setInstalling(false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const custom = skills.filter((s) => s.source === "custom");
|
|
129
|
+
const builtin = skills.filter((s) => s.source === "builtin");
|
|
130
|
+
|
|
131
|
+
if (editing) {
|
|
132
|
+
return (
|
|
133
|
+
<div className="flex h-full flex-col p-4">
|
|
134
|
+
<div className="flex items-center justify-between gap-2">
|
|
135
|
+
<h3 className="text-sm font-medium text-foreground">
|
|
136
|
+
{editing.isNew ? "New skill" : editing.name}
|
|
137
|
+
{editing.readOnly && (
|
|
138
|
+
<span className="ml-2 text-[11px] text-muted-foreground">built-in · read-only</span>
|
|
139
|
+
)}
|
|
140
|
+
</h3>
|
|
141
|
+
<div className="flex gap-2">
|
|
142
|
+
{!editing.readOnly && (
|
|
143
|
+
<button
|
|
144
|
+
onClick={() => void saveEditor()}
|
|
145
|
+
disabled={busy !== null}
|
|
146
|
+
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
|
|
147
|
+
>
|
|
148
|
+
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
|
149
|
+
Save
|
|
150
|
+
</button>
|
|
151
|
+
)}
|
|
152
|
+
<button
|
|
153
|
+
onClick={() => setEditing(null)}
|
|
154
|
+
className="flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
155
|
+
>
|
|
156
|
+
<X className="h-3 w-3" />
|
|
157
|
+
Close
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
|
162
|
+
<textarea
|
|
163
|
+
value={editing.content}
|
|
164
|
+
readOnly={editing.readOnly}
|
|
165
|
+
onChange={(e) => setEditing({ ...editing, content: e.target.value })}
|
|
166
|
+
spellCheck={false}
|
|
167
|
+
className="mt-3 min-h-0 flex-1 resize-none rounded-lg border border-border-subtle bg-background p-3 font-mono text-xs text-foreground focus:border-primary focus:outline-none"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div className="h-full overflow-y-auto p-4">
|
|
175
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
176
|
+
Your skills
|
|
177
|
+
</h3>
|
|
178
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
179
|
+
Custom skills live in your brain repo (<code>.agents/skills/</code>) — they
|
|
180
|
+
survive redeployments, ride the repo's backups, and apply to every
|
|
181
|
+
backend. For guided authoring, ask the agent in chat to{" "}
|
|
182
|
+
<span className="font-medium text-foreground">add a skill</span> — the
|
|
183
|
+
built-in <code>add-skill</code> skill walks through design and setup.
|
|
184
|
+
</p>
|
|
185
|
+
|
|
186
|
+
<div className="mt-4 flex items-center gap-2">
|
|
187
|
+
<input
|
|
188
|
+
value={newName}
|
|
189
|
+
onChange={(e) => setNewName(e.target.value.toLowerCase())}
|
|
190
|
+
onKeyDown={(e) => {
|
|
191
|
+
if (e.key === "Enter") startCreate();
|
|
192
|
+
}}
|
|
193
|
+
placeholder="new-skill-name (kebab-case)"
|
|
194
|
+
className="min-w-0 flex-1 rounded-md border border-border-subtle bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
|
195
|
+
/>
|
|
196
|
+
<button
|
|
197
|
+
onClick={startCreate}
|
|
198
|
+
disabled={!newName.trim()}
|
|
199
|
+
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
|
|
200
|
+
>
|
|
201
|
+
<Plus className="h-3 w-3" />
|
|
202
|
+
Create
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div className="mt-3 rounded-lg border border-border-subtle bg-surface p-3">
|
|
207
|
+
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
208
|
+
Install
|
|
209
|
+
</p>
|
|
210
|
+
<div className="mt-2 flex items-center gap-2">
|
|
211
|
+
<label
|
|
212
|
+
className={cn(
|
|
213
|
+
"flex cursor-pointer items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-primary hover:text-primary",
|
|
214
|
+
installing && "pointer-events-none opacity-50"
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
<Upload className="h-3 w-3" />
|
|
218
|
+
Upload .zip
|
|
219
|
+
<input
|
|
220
|
+
type="file"
|
|
221
|
+
accept=".zip,application/zip"
|
|
222
|
+
className="hidden"
|
|
223
|
+
onChange={(e) => {
|
|
224
|
+
const file = e.target.files?.[0];
|
|
225
|
+
e.target.value = "";
|
|
226
|
+
if (file) void install(() => api.skillInstallZip(file, overwrite));
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
</label>
|
|
230
|
+
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
231
|
+
<input
|
|
232
|
+
type="checkbox"
|
|
233
|
+
checked={overwrite}
|
|
234
|
+
onChange={(e) => setOverwrite(e.target.checked)}
|
|
235
|
+
/>
|
|
236
|
+
overwrite existing
|
|
237
|
+
</label>
|
|
238
|
+
</div>
|
|
239
|
+
<div className="mt-2 flex items-center gap-2">
|
|
240
|
+
<input
|
|
241
|
+
value={githubSource}
|
|
242
|
+
onChange={(e) => setGithubSource(e.target.value)}
|
|
243
|
+
onKeyDown={(e) => {
|
|
244
|
+
if (e.key === "Enter" && githubSource.trim() && !installing) {
|
|
245
|
+
void install(() => api.skillInstallGitHub(githubSource.trim(), overwrite));
|
|
246
|
+
}
|
|
247
|
+
}}
|
|
248
|
+
placeholder="GitHub: owner/repo or https://github.com/…/tree/main/skills"
|
|
249
|
+
className="min-w-0 flex-1 rounded-md border border-border-subtle bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
|
250
|
+
/>
|
|
251
|
+
<button
|
|
252
|
+
onClick={() =>
|
|
253
|
+
void install(() => api.skillInstallGitHub(githubSource.trim(), overwrite))
|
|
254
|
+
}
|
|
255
|
+
disabled={installing || !githubSource.trim()}
|
|
256
|
+
className="flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
|
|
257
|
+
>
|
|
258
|
+
{installing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
|
259
|
+
Install
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
<p className="mt-1.5 text-[11px] text-muted-foreground">
|
|
263
|
+
A skill is any folder with a SKILL.md; one source may carry several.
|
|
264
|
+
Private repos use the server's GITHUB_TOKEN.
|
|
265
|
+
</p>
|
|
266
|
+
{outcomes && (
|
|
267
|
+
<ul className="mt-2 flex flex-col gap-1">
|
|
268
|
+
{outcomes.map((o, i) => (
|
|
269
|
+
<li key={`${o.name}-${i}`} className="text-[11px]">
|
|
270
|
+
{o.status === "skipped" ? (
|
|
271
|
+
<span className="text-muted-foreground">
|
|
272
|
+
✗ {o.name} — {o.reason}
|
|
273
|
+
</span>
|
|
274
|
+
) : (
|
|
275
|
+
<span className="text-primary">
|
|
276
|
+
✓ {o.name} {o.status === "replaced" ? "replaced" : "installed"}
|
|
277
|
+
{typeof o.files === "number" ? ` (${o.files} file${o.files === 1 ? "" : "s"})` : ""}
|
|
278
|
+
</span>
|
|
279
|
+
)}
|
|
280
|
+
</li>
|
|
281
|
+
))}
|
|
282
|
+
</ul>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
|
287
|
+
{warning && <p className="mt-2 text-xs text-muted-foreground">{warning}</p>}
|
|
288
|
+
|
|
289
|
+
<ul className="mt-4 flex flex-col gap-2">
|
|
290
|
+
{custom.length === 0 && (
|
|
291
|
+
<li className="rounded-lg border border-dashed border-border-subtle p-3 text-xs text-muted-foreground">
|
|
292
|
+
No custom skills yet.
|
|
293
|
+
</li>
|
|
294
|
+
)}
|
|
295
|
+
{custom.map((skill) => (
|
|
296
|
+
<li
|
|
297
|
+
key={skill.name}
|
|
298
|
+
className={cn(
|
|
299
|
+
"rounded-lg border border-border-subtle bg-surface p-3",
|
|
300
|
+
!skill.enabled && "opacity-60"
|
|
301
|
+
)}
|
|
302
|
+
>
|
|
303
|
+
<div className="flex items-center gap-2">
|
|
304
|
+
<div className="min-w-0 flex-1">
|
|
305
|
+
<p className="truncate text-sm text-foreground">
|
|
306
|
+
{skill.name}
|
|
307
|
+
{!skill.enabled && (
|
|
308
|
+
<span className="ml-2 text-[11px] text-muted-foreground">disabled</span>
|
|
309
|
+
)}
|
|
310
|
+
</p>
|
|
311
|
+
<p className="truncate text-[11px] text-muted-foreground">
|
|
312
|
+
{skill.warning ? `⚠ ${skill.warning}` : skill.description}
|
|
313
|
+
</p>
|
|
314
|
+
</div>
|
|
315
|
+
<button
|
|
316
|
+
onClick={() => void openEditor(skill)}
|
|
317
|
+
disabled={busy !== null}
|
|
318
|
+
title="Edit SKILL.md"
|
|
319
|
+
className="rounded-md border border-border-subtle p-1.5 text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
|
|
320
|
+
>
|
|
321
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
322
|
+
</button>
|
|
323
|
+
<button
|
|
324
|
+
onClick={() =>
|
|
325
|
+
void run(skill.name, () => api.skillSetEnabled(skill.name, !skill.enabled))
|
|
326
|
+
}
|
|
327
|
+
disabled={busy !== null}
|
|
328
|
+
title={skill.enabled ? "Disable (all backends)" : "Enable"}
|
|
329
|
+
className={cn(
|
|
330
|
+
"rounded-md border border-border-subtle p-1.5 transition-colors disabled:opacity-50",
|
|
331
|
+
skill.enabled
|
|
332
|
+
? "text-primary hover:border-primary"
|
|
333
|
+
: "text-muted-foreground hover:border-primary hover:text-primary"
|
|
334
|
+
)}
|
|
335
|
+
>
|
|
336
|
+
{busy === skill.name ? (
|
|
337
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
338
|
+
) : (
|
|
339
|
+
<Power className="h-3.5 w-3.5" />
|
|
340
|
+
)}
|
|
341
|
+
</button>
|
|
342
|
+
<button
|
|
343
|
+
onClick={() => {
|
|
344
|
+
if (confirm(`Delete the skill "${skill.name}" permanently?`)) {
|
|
345
|
+
void run(skill.name, () => api.skillRemove(skill.name));
|
|
346
|
+
}
|
|
347
|
+
}}
|
|
348
|
+
disabled={busy !== null}
|
|
349
|
+
title="Delete permanently"
|
|
350
|
+
className="rounded-md border border-border-subtle p-1.5 text-muted-foreground transition-colors hover:border-destructive hover:text-destructive disabled:opacity-50"
|
|
351
|
+
>
|
|
352
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
</li>
|
|
356
|
+
))}
|
|
357
|
+
</ul>
|
|
358
|
+
|
|
359
|
+
<h3 className="mt-6 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
360
|
+
Built-in skills
|
|
361
|
+
</h3>
|
|
362
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
363
|
+
Shipped by brain-kit and its modules; managed by <code>brain skills sync</code>.
|
|
364
|
+
A custom skill with the same name overrides a built-in.
|
|
365
|
+
</p>
|
|
366
|
+
<ul className="mt-3 flex flex-col gap-1.5">
|
|
367
|
+
{builtin.map((skill) => (
|
|
368
|
+
<li
|
|
369
|
+
key={skill.name}
|
|
370
|
+
className="flex items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2"
|
|
371
|
+
>
|
|
372
|
+
<div className="min-w-0 flex-1">
|
|
373
|
+
<p className="truncate text-xs text-foreground">{skill.name}</p>
|
|
374
|
+
<p className="truncate text-[11px] text-muted-foreground">{skill.description}</p>
|
|
375
|
+
</div>
|
|
376
|
+
<button
|
|
377
|
+
onClick={() => void openEditor(skill)}
|
|
378
|
+
disabled={busy !== null}
|
|
379
|
+
title="View SKILL.md"
|
|
380
|
+
className="shrink-0 rounded-md border border-border-subtle px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
|
|
381
|
+
>
|
|
382
|
+
View
|
|
383
|
+
</button>
|
|
384
|
+
</li>
|
|
385
|
+
))}
|
|
386
|
+
</ul>
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { api } from "../../lib/api-client.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The user's remembered "always allow" tool grants — accumulated by the
|
|
7
|
+
* approval cards' "Always allow" button, revocable here. Renders nothing
|
|
8
|
+
* while the list is empty: the section only exists once there is something
|
|
9
|
+
* to manage.
|
|
10
|
+
*/
|
|
11
|
+
export function ToolPermissionsSection({ active }: { active: boolean }) {
|
|
12
|
+
const [tools, setTools] = useState<string[]>([]);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!active) return;
|
|
18
|
+
api
|
|
19
|
+
.toolPermissions()
|
|
20
|
+
.then(({ tools }) => setTools(tools))
|
|
21
|
+
.catch((err) =>
|
|
22
|
+
setError(err instanceof Error ? err.message : "Could not load tool permissions")
|
|
23
|
+
);
|
|
24
|
+
}, [active]);
|
|
25
|
+
|
|
26
|
+
async function revoke(tool: string) {
|
|
27
|
+
setBusy(tool);
|
|
28
|
+
setError(null);
|
|
29
|
+
try {
|
|
30
|
+
const { tools } = await api.toolPermissionRevoke(tool);
|
|
31
|
+
setTools(tools);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
setError(err instanceof Error ? err.message : "Could not revoke");
|
|
34
|
+
} finally {
|
|
35
|
+
setBusy(null);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (tools.length === 0 && !error) return null;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="mt-6">
|
|
43
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
44
|
+
Always-allowed tools
|
|
45
|
+
</h3>
|
|
46
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
47
|
+
These run without an approval card ("Always allow" on a past approval).
|
|
48
|
+
Destructive command confirmations still ask every time.
|
|
49
|
+
</p>
|
|
50
|
+
<ul className="mt-4 flex flex-col gap-1.5">
|
|
51
|
+
{tools.map((tool) => (
|
|
52
|
+
<li
|
|
53
|
+
key={tool}
|
|
54
|
+
className="flex items-center justify-between gap-3 rounded-lg border border-border-subtle bg-surface px-3 py-2"
|
|
55
|
+
>
|
|
56
|
+
<code className="truncate text-xs text-foreground">{tool}</code>
|
|
57
|
+
<button
|
|
58
|
+
onClick={() => void revoke(tool)}
|
|
59
|
+
disabled={busy === tool}
|
|
60
|
+
title="Ask for approval again"
|
|
61
|
+
className="flex shrink-0 items-center gap-1 rounded-md border border-border-subtle px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:border-destructive hover:text-destructive disabled:opacity-50"
|
|
62
|
+
>
|
|
63
|
+
<X className="h-3 w-3" />
|
|
64
|
+
Revoke
|
|
65
|
+
</button>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -256,7 +256,7 @@ export function handleServerMessage(msg: ServerMessage) {
|
|
|
256
256
|
break;
|
|
257
257
|
|
|
258
258
|
case "tool_approval_request":
|
|
259
|
-
state.requestToolApproval(key, msg.toolUseId, msg.toolName, msg.input, msg.description);
|
|
259
|
+
state.requestToolApproval(key, msg.toolUseId, msg.toolName, msg.input, msg.description, msg.kind);
|
|
260
260
|
break;
|
|
261
261
|
|
|
262
262
|
case "tool_result": {
|
package/src/lib/api-client.ts
CHANGED
|
@@ -105,6 +105,30 @@ export interface PiLoginFlow {
|
|
|
105
105
|
startedAt: number;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** One managed skill row (mirror of the server view). */
|
|
109
|
+
export interface SkillEntry {
|
|
110
|
+
name: string;
|
|
111
|
+
description: string;
|
|
112
|
+
/** "builtin" = shipped by brain-kit/modules (read-only); "custom" = the user's. */
|
|
113
|
+
source: "builtin" | "custom";
|
|
114
|
+
enabled: boolean;
|
|
115
|
+
warning?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One skill with its SKILL.md content. */
|
|
119
|
+
export interface SkillDetail extends SkillEntry {
|
|
120
|
+
content: string;
|
|
121
|
+
extraFiles: string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Per-skill result of an archive/GitHub install. */
|
|
125
|
+
export interface SkillInstallOutcome {
|
|
126
|
+
name: string;
|
|
127
|
+
status: "installed" | "replaced" | "skipped";
|
|
128
|
+
reason?: string;
|
|
129
|
+
files?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
108
132
|
/** One selectable web-search provider (mirror of the server view). */
|
|
109
133
|
export interface WebSearchProvider {
|
|
110
134
|
id: string;
|
|
@@ -282,6 +306,76 @@ export const api = {
|
|
|
282
306
|
body: JSON.stringify({ providerId }),
|
|
283
307
|
}),
|
|
284
308
|
|
|
309
|
+
/** Custom + built-in skills, as managed from Settings → Skills. */
|
|
310
|
+
skillsList: () => fetchJson<{ skills: SkillEntry[] }>("/skills"),
|
|
311
|
+
|
|
312
|
+
/** One skill's SKILL.md and file list (builtins read-only). */
|
|
313
|
+
skillGet: (name: string) =>
|
|
314
|
+
fetchJson<SkillDetail>(`/skills/${encodeURIComponent(name)}`),
|
|
315
|
+
|
|
316
|
+
/** Create a custom skill; runs `brain skills sync` server-side. */
|
|
317
|
+
skillCreate: (name: string, content: string) =>
|
|
318
|
+
fetchJson<{ skill: SkillEntry; warning?: string }>("/skills", {
|
|
319
|
+
method: "POST",
|
|
320
|
+
body: JSON.stringify({ name, content }),
|
|
321
|
+
}),
|
|
322
|
+
|
|
323
|
+
/** Replace a custom skill's SKILL.md. */
|
|
324
|
+
skillUpdate: (name: string, content: string) =>
|
|
325
|
+
fetchJson<{ skill: SkillEntry; warning?: string }>(`/skills/${encodeURIComponent(name)}`, {
|
|
326
|
+
method: "PUT",
|
|
327
|
+
body: JSON.stringify({ content }),
|
|
328
|
+
}),
|
|
329
|
+
|
|
330
|
+
/** Enable/disable a custom skill (applies to every backend at once). */
|
|
331
|
+
skillSetEnabled: (name: string, enabled: boolean) =>
|
|
332
|
+
fetchJson<{ skill: SkillEntry; warning?: string }>(
|
|
333
|
+
`/skills/${encodeURIComponent(name)}/enabled`,
|
|
334
|
+
{ method: "POST", body: JSON.stringify({ enabled }) }
|
|
335
|
+
),
|
|
336
|
+
|
|
337
|
+
/** Delete a custom skill permanently. */
|
|
338
|
+
skillRemove: (name: string) =>
|
|
339
|
+
fetchJson<{ ok: boolean; warning?: string }>(`/skills/${encodeURIComponent(name)}`, {
|
|
340
|
+
method: "DELETE",
|
|
341
|
+
}),
|
|
342
|
+
|
|
343
|
+
/** Install skill(s) from an uploaded ZIP archive. */
|
|
344
|
+
skillInstallZip: async (file: File, overwrite: boolean) => {
|
|
345
|
+
const form = new FormData();
|
|
346
|
+
form.append("file", file);
|
|
347
|
+
form.append("overwrite", overwrite ? "true" : "false");
|
|
348
|
+
// Raw fetch: the browser must set the multipart boundary itself.
|
|
349
|
+
const res = await fetch(`${apiBase()}/skills/install/zip`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
body: form,
|
|
352
|
+
});
|
|
353
|
+
const body = (await res.json().catch(() => null)) as
|
|
354
|
+
| { outcomes?: SkillInstallOutcome[]; warning?: string; error?: string }
|
|
355
|
+
| null;
|
|
356
|
+
if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`);
|
|
357
|
+
return body as { outcomes: SkillInstallOutcome[]; warning?: string };
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
/** Install skill(s) from a GitHub repository (owner/repo or URL). */
|
|
361
|
+
skillInstallGitHub: (source: string, overwrite: boolean, ref?: string) =>
|
|
362
|
+
fetchJson<{ outcomes: SkillInstallOutcome[]; warning?: string }>(
|
|
363
|
+
"/skills/install/github",
|
|
364
|
+
{
|
|
365
|
+
method: "POST",
|
|
366
|
+
body: JSON.stringify({ source, overwrite, ...(ref ? { ref } : {}) }),
|
|
367
|
+
}
|
|
368
|
+
),
|
|
369
|
+
|
|
370
|
+
/** Tools remembered as "always allow" (auto-approved without a card). */
|
|
371
|
+
toolPermissions: () => fetchJson<{ tools: string[] }>("/tool-permissions"),
|
|
372
|
+
|
|
373
|
+
/** Revoke one remembered tool grant; returns the updated list. */
|
|
374
|
+
toolPermissionRevoke: (tool: string) =>
|
|
375
|
+
fetchJson<{ tools: string[] }>(`/tool-permissions/${encodeURIComponent(tool)}`, {
|
|
376
|
+
method: "DELETE",
|
|
377
|
+
}),
|
|
378
|
+
|
|
285
379
|
/** Web-search provider config; `configured: false` when pi is not in play. */
|
|
286
380
|
webSearchConfig: () => fetchJson<WebSearchConfig>("/web-search"),
|
|
287
381
|
|
package/src/stores/chat-store.ts
CHANGED
|
@@ -50,6 +50,11 @@ export interface ToolCall {
|
|
|
50
50
|
output?: string;
|
|
51
51
|
isError?: boolean;
|
|
52
52
|
status: "streaming" | "pending_approval" | "approved" | "denied" | "complete";
|
|
53
|
+
/**
|
|
54
|
+
* What the pending approval is for: "tool" may be remembered via "Always
|
|
55
|
+
* allow"; "command" (a destructive-bash confirmation) is per-use only.
|
|
56
|
+
*/
|
|
57
|
+
approvalKind?: "tool" | "command";
|
|
53
58
|
/**
|
|
54
59
|
* Execution timing for the duration badge. `startedAt` is (re)stamped when
|
|
55
60
|
* the input finishes streaming or an approval is granted — so approval
|
|
@@ -159,7 +164,8 @@ interface ChatState {
|
|
|
159
164
|
toolUseId: string,
|
|
160
165
|
toolName: string,
|
|
161
166
|
input: Record<string, unknown>,
|
|
162
|
-
description?: string
|
|
167
|
+
description?: string,
|
|
168
|
+
kind?: "tool" | "command"
|
|
163
169
|
) => void;
|
|
164
170
|
resolveToolApproval: (key: ChatKey, toolUseId: string, approved: boolean) => void;
|
|
165
171
|
setToolResult: (key: ChatKey, toolUseId: string, output: string, isError: boolean) => void;
|
|
@@ -465,7 +471,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|
|
465
471
|
),
|
|
466
472
|
})),
|
|
467
473
|
|
|
468
|
-
requestToolApproval: (key, toolUseId, toolName, input, _description) =>
|
|
474
|
+
requestToolApproval: (key, toolUseId, toolName, input, _description, kind) =>
|
|
469
475
|
mutateLastAssistant(key, (last) => {
|
|
470
476
|
// Check if tool call already exists (from streaming)
|
|
471
477
|
const existingIdx = last.toolCalls.findIndex(
|
|
@@ -478,6 +484,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|
|
478
484
|
input,
|
|
479
485
|
inputJson: JSON.stringify(input, null, 2),
|
|
480
486
|
status: "pending_approval",
|
|
487
|
+
...(kind ? { approvalKind: kind } : {}),
|
|
481
488
|
};
|
|
482
489
|
let parts = last.parts;
|
|
483
490
|
if (existingIdx >= 0) {
|
package/src/stores/ui-store.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { create } from "zustand";
|
|
2
2
|
|
|
3
3
|
/** Which tab the settings panel opens on. */
|
|
4
|
-
export type SettingsTab = "models" | "security";
|
|
4
|
+
export type SettingsTab = "models" | "skills" | "security";
|
|
5
5
|
|
|
6
6
|
/** Full-screen surface currently shown inside the AppShell. */
|
|
7
7
|
export type ActiveView = "chat" | "graph" | "activity";
|